diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 627edd9ed..45583c597 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -75,13 +75,13 @@ body:
- type: dropdown
id: mobile-browsers
attributes:
- label: If the issue is being seen on the UI, what browsers are you seeing the problem on?
+ label: If the issue is being seen on the Mobile UI, what browsers are you seeing the problem on?
multiple: true
options:
- Firefox
- Chrome
- Safari
- - Microsoft Edge
+ - Other iOS Browser
- type: textarea
id: logs
attributes:
diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index 98ce4c439..8a6b9c2f6 100644
--- a/.github/workflows/build-and-test.yml
+++ b/.github/workflows/build-and-test.yml
@@ -10,12 +10,12 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout Repo
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup .NET Core
- uses: actions/setup-dotnet@v3
+ uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
@@ -26,7 +26,7 @@ jobs:
- name: Install dependencies
run: dotnet restore
- - uses: actions/upload-artifact@v3
+ - uses: actions/upload-artifact@v4
with:
name: csproj
path: Kavita.Common/Kavita.Common.csproj
diff --git a/.github/workflows/canary-workflow.yml b/.github/workflows/canary-workflow.yml
index af4a45dec..32eb2d01f 100644
--- a/.github/workflows/canary-workflow.yml
+++ b/.github/workflows/canary-workflow.yml
@@ -12,11 +12,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
fetch-depth: 0
- - uses: actions/upload-artifact@v3
+ - uses: actions/upload-artifact@v4
with:
name: csproj
path: Kavita.Common/Kavita.Common.csproj
@@ -26,12 +26,12 @@ jobs:
needs: [ build ]
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup .NET Core
- uses: actions/setup-dotnet@v3
+ uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
@@ -59,14 +59,14 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Check Out Repo
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
ref: canary
- name: NodeJS to Compile WebUI
- uses: actions/setup-node@v3
+ uses: actions/setup-node@v4
with:
- node-version: '18.13.x'
+ node-version: 20
- run: |
cd UI/Web || exit
echo 'Installing web dependencies'
@@ -81,7 +81,7 @@ jobs:
cd ../ || exit
- name: Get csproj Version
- uses: kzrnm/get-net-sdk-project-versions-action@v1
+ uses: kzrnm/get-net-sdk-project-versions-action@v2
id: get-version
with:
proj-path: Kavita.Common/Kavita.Common.csproj
@@ -96,7 +96,7 @@ jobs:
run: echo "${{steps.get-version.outputs.assembly-version}}"
- name: Compile dotnet app
- uses: actions/setup-dotnet@v3
+ uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
@@ -106,28 +106,28 @@ jobs:
- run: ./monorepo-build.sh
- name: Login to Docker Hub
- uses: docker/login-action@v2
+ uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Login to GitHub Container Registry
- uses: docker/login-action@v2
+ uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2
+ uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
id: buildx
- uses: docker/setup-buildx-action@v2
+ uses: docker/setup-buildx-action@v3
- name: Build and push
id: docker_build
- uses: docker/build-push-action@v4
+ uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 53103f850..6f77e6547 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -46,7 +46,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Install Swashbuckle CLI
shell: bash
diff --git a/.github/workflows/develop-workflow.yml b/.github/workflows/develop-workflow.yml
index dff82c01e..d97b7f8cf 100644
--- a/.github/workflows/develop-workflow.yml
+++ b/.github/workflows/develop-workflow.yml
@@ -2,10 +2,7 @@ name: Nightly Workflow
on:
push:
- branches: ['!release/**']
- pull_request:
branches: [ 'develop', '!release/**' ]
- types: [ closed ]
workflow_dispatch:
jobs:
@@ -21,14 +18,14 @@ jobs:
build:
name: Upload Kavita.Common for Version Bump
runs-on: ubuntu-latest
- if: github.event.pull_request.merged == true && !contains(github.head_ref, 'release')
+ if: github.ref == 'refs/heads/develop'
steps:
- name: Checkout Repo
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
fetch-depth: 0
- - uses: actions/upload-artifact@v3
+ - uses: actions/upload-artifact@v4
with:
name: csproj
path: Kavita.Common/Kavita.Common.csproj
@@ -37,14 +34,14 @@ jobs:
name: Bump version
needs: [ build ]
runs-on: ubuntu-latest
- if: github.event.pull_request.merged == true && !contains(github.head_ref, 'release')
+ if: github.ref == 'refs/heads/develop'
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup .NET Core
- uses: actions/setup-dotnet@v3
+ uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
@@ -59,7 +56,7 @@ jobs:
name: Build Nightly Docker
needs: [ build, version ]
runs-on: ubuntu-latest
- if: github.event.pull_request.merged == true && !contains(github.head_ref, 'release')
+ if: github.ref == 'refs/heads/develop'
permissions:
packages: write
contents: read
@@ -92,14 +89,14 @@ jobs:
echo "BODY=$body" >> $GITHUB_OUTPUT
- name: Check Out Repo
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
ref: develop
- name: NodeJS to Compile WebUI
- uses: actions/setup-node@v3
+ uses: actions/setup-node@v4
with:
- node-version: '18.13.x'
+ node-version: 20
- run: |
cd UI/Web || exit
echo 'Installing web dependencies'
@@ -114,7 +111,7 @@ jobs:
cd ../ || exit
- name: Get csproj Version
- uses: kzrnm/get-net-sdk-project-versions-action@v1
+ uses: kzrnm/get-net-sdk-project-versions-action@v2
id: get-version
with:
proj-path: Kavita.Common/Kavita.Common.csproj
@@ -129,7 +126,7 @@ jobs:
run: echo "${{steps.get-version.outputs.assembly-version}}"
- name: Compile dotnet app
- uses: actions/setup-dotnet@v3
+ uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
@@ -139,28 +136,28 @@ jobs:
- run: ./monorepo-build.sh
- name: Login to Docker Hub
- uses: docker/login-action@v2
+ uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Login to GitHub Container Registry
- uses: docker/login-action@v2
+ uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2
+ uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
id: buildx
- uses: docker/setup-buildx-action@v2
+ uses: docker/setup-buildx-action@v3
- name: Build and push
id: docker_build
- uses: docker/build-push-action@v4
+ uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml
index ca1314e8b..84a381e0b 100644
--- a/.github/workflows/release-workflow.yml
+++ b/.github/workflows/release-workflow.yml
@@ -30,11 +30,11 @@ jobs:
if: github.event.pull_request.merged == true && contains(github.head_ref, 'release')
steps:
- name: Checkout Repo
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
fetch-depth: 0
- - uses: actions/upload-artifact@v3
+ - uses: actions/upload-artifact@v4
with:
name: csproj
path: Kavita.Common/Kavita.Common.csproj
@@ -77,14 +77,14 @@ jobs:
echo "BODY=$body" >> $GITHUB_OUTPUT
- name: Check Out Repo
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
ref: develop
- name: NodeJS to Compile WebUI
- uses: actions/setup-node@v3
+ uses: actions/setup-node@v4
with:
- node-version: '18.13.x'
+ node-version: 20
- run: |
cd UI/Web || exit
@@ -100,7 +100,7 @@ jobs:
cd ../ || exit
- name: Get csproj Version
- uses: kzrnm/get-net-sdk-project-versions-action@v1
+ uses: kzrnm/get-net-sdk-project-versions-action@v2
id: get-version
with:
proj-path: Kavita.Common/Kavita.Common.csproj
@@ -117,7 +117,7 @@ jobs:
id: parse-version
- name: Compile dotnet app
- uses: actions/setup-dotnet@v3
+ uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Install Swashbuckle CLI
@@ -126,28 +126,28 @@ jobs:
- run: ./monorepo-build.sh
- name: Login to Docker Hub
- uses: docker/login-action@v2
+ uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Login to GitHub Container Registry
- uses: docker/login-action@v2
+ uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2
+ uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
id: buildx
- uses: docker/setup-buildx-action@v2
+ uses: docker/setup-buildx-action@v3
- name: Build and push stable
id: docker_build_stable
- uses: docker/build-push-action@v4
+ uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
@@ -156,7 +156,7 @@ jobs:
- name: Build and push nightly
id: docker_build_nightly
- uses: docker/build-push-action@v4
+ uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
diff --git a/.gitignore b/.gitignore
index bb124fc7f..584f0026e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -520,6 +520,7 @@ UI/Web/dist/
/API/config/*.db
/API/config/*.bak
/API/config/*.backup
+/API/config/*.csv
/API/config/Hangfire.db
/API/config/Hangfire-log.db
API/config/covers/
diff --git a/.sonarcloud.properties b/.sonarcloud.properties
new file mode 100644
index 000000000..1876ac55a
--- /dev/null
+++ b/.sonarcloud.properties
@@ -0,0 +1,15 @@
+# Path to sources
+sonar.sources=.
+sonar.exclusions=API.Benchmark
+#sonar.inclusions=
+
+# Path to tests
+sonar.tests=API.Tests
+#sonar.test.exclusions=
+#sonar.test.inclusions=
+
+# Source encoding
+sonar.sourceEncoding=UTF-8
+
+# Exclusions for copy-paste detection
+#sonar.cpd.exclusions=
diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj
index 624cef936..052226f56 100644
--- a/API.Tests/API.Tests.csproj
+++ b/API.Tests/API.Tests.csproj
@@ -9,8 +9,8 @@
-
-
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/API.Tests/AbstractDbTest.cs b/API.Tests/AbstractDbTest.cs
index 18f0669cd..a3464db9d 100644
--- a/API.Tests/AbstractDbTest.cs
+++ b/API.Tests/AbstractDbTest.cs
@@ -10,6 +10,7 @@ using API.Helpers;
using API.Helpers.Builders;
using API.Services;
using AutoMapper;
+using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -47,6 +48,7 @@ public abstract class AbstractDbTest
var config = new MapperConfiguration(cfg => cfg.AddProfile());
var mapper = config.CreateMapper();
+
_unitOfWork = new UnitOfWork(_context, mapper, null);
}
diff --git a/API.Tests/Extensions/EnumerableExtensionsTests.cs b/API.Tests/Extensions/EnumerableExtensionsTests.cs
index e115d45f3..bdd3433ae 100644
--- a/API.Tests/Extensions/EnumerableExtensionsTests.cs
+++ b/API.Tests/Extensions/EnumerableExtensionsTests.cs
@@ -74,10 +74,10 @@ public class EnumerableExtensionsTests
new[] {@"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\001.jpg", @"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\002.jpg"},
new[] {@"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\001.jpg", @"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\002.jpg"}
)]
- [InlineData(
- new[] {"01/001.jpg", "001.jpg"},
- new[] {"001.jpg", "01/001.jpg"}
- )]
+ [InlineData(
+ new[] {"01/001.jpg", "001.jpg"},
+ new[] {"001.jpg", "01/001.jpg"}
+ )]
public void TestNaturalSort(string[] input, string[] expected)
{
Assert.Equal(expected, input.OrderByNatural(x => x).ToArray());
diff --git a/API.Tests/Extensions/QueryableExtensionsTests.cs b/API.Tests/Extensions/QueryableExtensionsTests.cs
index 230028d44..771ba940c 100644
--- a/API.Tests/Extensions/QueryableExtensionsTests.cs
+++ b/API.Tests/Extensions/QueryableExtensionsTests.cs
@@ -45,17 +45,17 @@ public class QueryableExtensionsTests
[InlineData(false, 1)]
public void RestrictAgainstAgeRestriction_CollectionTag_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount)
{
- var items = new List()
+ var items = new List()
{
- new CollectionTagBuilder("Test")
- .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build())
+ new AppUserCollectionBuilder("Test")
+ .WithItem(new SeriesBuilder("S1").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()).Build())
.Build(),
- new CollectionTagBuilder("Test 2")
- .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Unknown).Build())
- .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build())
+ new AppUserCollectionBuilder("Test 2")
+ .WithItem(new SeriesBuilder("S2").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Unknown).Build()).Build())
+ .WithItem(new SeriesBuilder("S1").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()).Build())
.Build(),
- new CollectionTagBuilder("Test 3")
- .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.X18Plus).Build())
+ new AppUserCollectionBuilder("Test 3")
+ .WithItem(new SeriesBuilder("S3").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.X18Plus).Build()).Build())
.Build(),
};
diff --git a/API.Tests/Parsers/DefaultParserTests.cs b/API.Tests/Parsers/DefaultParserTests.cs
index fcedc779e..9dc926ef5 100644
--- a/API.Tests/Parsers/DefaultParserTests.cs
+++ b/API.Tests/Parsers/DefaultParserTests.cs
@@ -123,7 +123,7 @@ public class DefaultParserTests
FullFilePath = filepath
});
- filepath = @"E:\Manga\Beelzebub\Beelzebub_01_[Noodles].zip";
+ filepath = @"E:/Manga/Beelzebub/Beelzebub_01_[Noodles].zip";
expected.Add(filepath, new ParserInfo
{
Series = "Beelzebub", Volumes = Parser.LooseLeafVolume,
@@ -132,7 +132,7 @@ public class DefaultParserTests
});
// Note: Lots of duplicates here. I think I can move them to the ParserTests itself
- filepath = @"E:\Manga\Ichinensei ni Nacchattara\Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip";
+ filepath = @"E:/Manga/Ichinensei ni Nacchattara/Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip";
expected.Add(filepath, new ParserInfo
{
Series = "Ichinensei ni Nacchattara", Volumes = "1",
@@ -140,7 +140,7 @@ public class DefaultParserTests
FullFilePath = filepath
});
- filepath = @"E:\Manga\Tenjo Tenge (Color)\Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz";
+ filepath = @"E:/Manga/Tenjo Tenge (Color)/Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz";
expected.Add(filepath, new ParserInfo
{
Series = "Tenjo Tenge {Full Contact Edition}", Volumes = "1", Edition = "",
@@ -148,7 +148,7 @@ public class DefaultParserTests
FullFilePath = filepath
});
- filepath = @"E:\Manga\Akame ga KILL! ZERO (2016-2019) (Digital) (LuCaZ)\Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz";
+ filepath = @"E:/Manga/Akame ga KILL! ZERO (2016-2019) (Digital) (LuCaZ)/Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz";
expected.Add(filepath, new ParserInfo
{
Series = "Akame ga KILL! ZERO", Volumes = "1", Edition = "",
@@ -156,7 +156,7 @@ public class DefaultParserTests
FullFilePath = filepath
});
- filepath = @"E:\Manga\Dorohedoro\Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz";
+ filepath = @"E:/Manga/Dorohedoro/Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz";
expected.Add(filepath, new ParserInfo
{
Series = "Dorohedoro", Volumes = "1", Edition = "",
@@ -164,7 +164,7 @@ public class DefaultParserTests
FullFilePath = filepath
});
- filepath = @"E:\Manga\APOSIMZ\APOSIMZ 040 (2020) (Digital) (danke-Empire).cbz";
+ filepath = @"E:/Manga/APOSIMZ/APOSIMZ 040 (2020) (Digital) (danke-Empire).cbz";
expected.Add(filepath, new ParserInfo
{
Series = "APOSIMZ", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
@@ -172,7 +172,7 @@ public class DefaultParserTests
FullFilePath = filepath
});
- filepath = @"E:\Manga\Corpse Party Musume\Kedouin Makoto - Corpse Party Musume, Chapter 09.cbz";
+ filepath = @"E:/Manga/Corpse Party Musume/Kedouin Makoto - Corpse Party Musume, Chapter 09.cbz";
expected.Add(filepath, new ParserInfo
{
Series = "Kedouin Makoto - Corpse Party Musume", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
@@ -180,7 +180,7 @@ public class DefaultParserTests
FullFilePath = filepath
});
- filepath = @"E:\Manga\Goblin Slayer\Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire).cbz";
+ filepath = @"E:/Manga/Goblin Slayer/Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire).cbz";
expected.Add(filepath, new ParserInfo
{
Series = "Goblin Slayer - Brand New Day", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
@@ -188,7 +188,7 @@ public class DefaultParserTests
FullFilePath = filepath
});
- filepath = @"E:\Manga\Summer Time Rendering\Specials\Record 014 (between chapter 083 and ch084) SP11.cbr";
+ filepath = @"E:/Manga/Summer Time Rendering/Specials/Record 014 (between chapter 083 and ch084) SP11.cbr";
expected.Add(filepath, new ParserInfo
{
Series = "Summer Time Rendering", Volumes = API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume, Edition = "",
@@ -196,7 +196,7 @@ public class DefaultParserTests
FullFilePath = filepath, IsSpecial = true
});
- filepath = @"E:\Manga\Seraph of the End\Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ).cbz";
+ filepath = @"E:/Manga/Seraph of the End/Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ).cbz";
expected.Add(filepath, new ParserInfo
{
Series = "Seraph of the End - Vampire Reign", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
@@ -204,7 +204,7 @@ public class DefaultParserTests
FullFilePath = filepath, IsSpecial = false
});
- filepath = @"E:\Manga\Kono Subarashii Sekai ni Bakuen wo!\Vol. 00 Ch. 000.cbz";
+ filepath = @"E:/Manga/Kono Subarashii Sekai ni Bakuen wo!/Vol. 00 Ch. 000.cbz";
expected.Add(filepath, new ParserInfo
{
Series = "Kono Subarashii Sekai ni Bakuen wo!", Volumes = "0", Edition = "",
@@ -212,7 +212,7 @@ public class DefaultParserTests
FullFilePath = filepath, IsSpecial = false
});
- filepath = @"E:\Manga\Toukyou Akazukin\Vol. 01 Ch. 001.cbz";
+ filepath = @"E:/Manga/Toukyou Akazukin/Vol. 01 Ch. 001.cbz";
expected.Add(filepath, new ParserInfo
{
Series = "Toukyou Akazukin", Volumes = "1", Edition = "",
@@ -221,10 +221,10 @@ public class DefaultParserTests
});
// If an image is cover exclusively, ignore it
- filepath = @"E:\Manga\Seraph of the End\cover.png";
+ filepath = @"E:/Manga/Seraph of the End/cover.png";
expected.Add(filepath, null);
- filepath = @"E:\Manga\The Beginning After the End\Chapter 001.cbz";
+ filepath = @"E:/Manga/The Beginning After the End/Chapter 001.cbz";
expected.Add(filepath, new ParserInfo
{
Series = "The Beginning After the End", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
@@ -232,7 +232,7 @@ public class DefaultParserTests
FullFilePath = filepath, IsSpecial = false
});
- filepath = @"E:\Manga\Air Gear\Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz";
+ filepath = @"E:/Manga/Air Gear/Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz";
expected.Add(filepath, new ParserInfo
{
Series = "Air Gear", Volumes = "1", Edition = "Omnibus",
@@ -240,7 +240,7 @@ public class DefaultParserTests
FullFilePath = filepath, IsSpecial = false
});
- filepath = @"E:\Manga\Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub";
+ filepath = @"E:/Manga/Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub";
expected.Add(filepath, new ParserInfo
{
Series = "Harrison, Kim - The Good, The Bad, and the Undead - Hollows", Volumes = "2.5", Edition = "",
@@ -279,17 +279,17 @@ public class DefaultParserTests
//[Fact]
public void Parse_ParseInfo_Manga_ImageOnly()
{
- // Images don't have root path as E:\Manga, but rather as the path of the folder
+ // Images don't have root path as E:/Manga, but rather as the path of the folder
// Note: Fallback to folder will parse Monster #8 and get Monster
- var filepath = @"E:\Manga\Monster #8\Ch. 001-016 [MangaPlus] [Digital] [amit34521]\Monster #8 Ch. 001 [MangaPlus] [Digital] [amit34521]\13.jpg";
+ var filepath = @"E:/Manga/Monster #8/Ch. 001-016 [MangaPlus] [Digital] [amit34521]/Monster #8 Ch. 001 [MangaPlus] [Digital] [amit34521]/13.jpg";
var expectedInfo2 = new ParserInfo
{
Series = "Monster #8", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image,
FullFilePath = filepath, IsSpecial = false
};
- var actual2 = _defaultParser.Parse(filepath, @"E:\Manga\Monster #8", "E:/Manga", LibraryType.Manga, null);
+ var actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Monster #8", "E:/Manga", LibraryType.Manga, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@@ -307,7 +307,7 @@ public class DefaultParserTests
Assert.Equal(expectedInfo2.FullFilePath, actual2.FullFilePath);
_testOutputHelper.WriteLine("FullFilePath ✓");
- filepath = @"E:\Manga\Extra layer for no reason\Just Images the second\Vol19\ch. 186\Vol. 19 p106.gif";
+ filepath = @"E:/Manga/Extra layer for no reason/Just Images the second/Vol19/ch. 186/Vol. 19 p106.gif";
expectedInfo2 = new ParserInfo
{
Series = "Just Images the second", Volumes = "19", Edition = "",
@@ -315,7 +315,7 @@ public class DefaultParserTests
FullFilePath = filepath, IsSpecial = false
};
- actual2 = _defaultParser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga",LibraryType.Manga, null);
+ actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga",LibraryType.Manga, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@@ -333,7 +333,7 @@ public class DefaultParserTests
Assert.Equal(expectedInfo2.FullFilePath, actual2.FullFilePath);
_testOutputHelper.WriteLine("FullFilePath ✓");
- filepath = @"E:\Manga\Extra layer for no reason\Just Images the second\Blank Folder\Vol19\ch. 186\Vol. 19 p106.gif";
+ filepath = @"E:/Manga/Extra layer for no reason/Just Images the second/Blank Folder/Vol19/ch. 186/Vol. 19 p106.gif";
expectedInfo2 = new ParserInfo
{
Series = "Just Images the second", Volumes = "19", Edition = "",
@@ -341,7 +341,7 @@ public class DefaultParserTests
FullFilePath = filepath, IsSpecial = false
};
- actual2 = _defaultParser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Manga, null);
+ actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga", LibraryType.Manga, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@@ -448,7 +448,7 @@ public class DefaultParserTests
});
// Fallback test with bad naming
- filepath = @"E:\Comics\Comics\Babe\Babe Vol.1 #1-4\Babe 01.cbr";
+ filepath = @"E:/Comics/Comics/Babe/Babe Vol.1 #1-4/Babe 01.cbr";
expected.Add(filepath, new ParserInfo
{
Series = "Babe", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
@@ -456,7 +456,7 @@ public class DefaultParserTests
FullFilePath = filepath, IsSpecial = false
});
- filepath = @"E:\Comics\Comics\Publisher\Batman the Detective (2021)\Batman the Detective - v6 - 11 - (2021).cbr";
+ filepath = @"E:/Comics/Comics/Publisher/Batman the Detective (2021)/Batman the Detective - v6 - 11 - (2021).cbr";
expected.Add(filepath, new ParserInfo
{
Series = "Batman the Detective", Volumes = "6", Edition = "",
@@ -464,7 +464,7 @@ public class DefaultParserTests
FullFilePath = filepath, IsSpecial = false
});
- filepath = @"E:\Comics\Comics\Batman - The Man Who Laughs #1 (2005)\Batman - The Man Who Laughs #1 (2005).cbr";
+ filepath = @"E:/Comics/Comics/Batman - The Man Who Laughs #1 (2005)/Batman - The Man Who Laughs #1 (2005).cbr";
expected.Add(filepath, new ParserInfo
{
Series = "Batman - The Man Who Laughs", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
diff --git a/API.Tests/Parsing/ComicParsingTests.cs b/API.Tests/Parsing/ComicParsingTests.cs
index 1d0f4ae69..4bb2948b1 100644
--- a/API.Tests/Parsing/ComicParsingTests.cs
+++ b/API.Tests/Parsing/ComicParsingTests.cs
@@ -78,6 +78,8 @@ public class ComicParsingTests
[InlineData("Fables 2010 Vol. 1 Legends in Exile", "Fables 2010")]
[InlineData("Kebab Том 1 Глава 1", "Kebab")]
[InlineData("Манга Глава 1", "Манга")]
+ [InlineData("ReZero รีเซทชีวิต ฝ่าวิกฤตต่างโลก เล่ม 1", "ReZero รีเซทชีวิต ฝ่าวิกฤตต่างโลก")]
+ [InlineData("SKY WORLD สกายเวิลด์ เล่มที่ 1", "SKY WORLD สกายเวิลด์")]
public void ParseComicSeriesTest(string filename, string expected)
{
Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicSeries(filename));
@@ -129,6 +131,9 @@ public class ComicParsingTests
// Russian Tests
[InlineData("Kebab Том 1 Глава 3", "1")]
[InlineData("Манга Глава 2", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)]
+ [InlineData("ย้อนเวลากลับมาร้าย เล่ม 1", "1")]
+ [InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1 ตอนที่ 3", "1")]
+ [InlineData("วิวาห์รัก เดิมพันชีวิต ตอนที่ 2", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)]
public void ParseComicVolumeTest(string filename, string expected)
{
Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicVolume(filename));
@@ -178,6 +183,9 @@ public class ComicParsingTests
[InlineData("Манга Глава 2", "2")]
[InlineData("Манга 2 Глава", "2")]
[InlineData("Манга Том 1 2 Глава", "2")]
+ [InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1 ตอนที่ 3", "3")]
+ [InlineData("Max Level Returner ตอนที่ 5", "5")]
+ [InlineData("หนึ่งความคิด นิจนิรันดร์ บทที่ 112", "112")]
public void ParseComicChapterTest(string filename, string expected)
{
Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicChapter(filename));
diff --git a/API.Tests/Parsing/MangaParsingTests.cs b/API.Tests/Parsing/MangaParsingTests.cs
index dcb3501e1..446c9e782 100644
--- a/API.Tests/Parsing/MangaParsingTests.cs
+++ b/API.Tests/Parsing/MangaParsingTests.cs
@@ -206,6 +206,10 @@ public class MangaParsingTests
[InlineData("test 2 years 1권", "test 2 years")]
[InlineData("test 2 years 1화", "test 2 years")]
[InlineData("Nagasarete Airantou - Vol. 30 Ch. 187.5 - Vol.30 Omake", "Nagasarete Airantou")]
+ [InlineData("Cynthia The Mission - c000 - c006 (v06)", "Cynthia The Mission")]
+ [InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1", "เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท")]
+ [InlineData("Max Level Returner เล่มที่ 5", "Max Level Returner")]
+ [InlineData("หนึ่งความคิด นิจนิรันดร์ เล่ม 2", "หนึ่งความคิด นิจนิรันดร์")]
public void ParseSeriesTest(string filename, string expected)
{
Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename));
@@ -295,6 +299,9 @@ public class MangaParsingTests
[InlineData("Historys Strongest Disciple Kenichi_v11_c90-98", "90-98")]
[InlineData("Historys Strongest Disciple Kenichi c01-c04", "1-4")]
[InlineData("Adabana c00-02", "0-2")]
+ [InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1 ตอนที่ 3", "3")]
+ [InlineData("Max Level Returner ตอนที่ 5", "5")]
+ [InlineData("หนึ่งความคิด นิจนิรันดร์ บทที่ 112", "112")]
public void ParseChaptersTest(string filename, string expected)
{
Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseChapter(filename));
diff --git a/API.Tests/Repository/CollectionTagRepositoryTests.cs b/API.Tests/Repository/CollectionTagRepositoryTests.cs
index 1859ab1fc..6abf3f7e7 100644
--- a/API.Tests/Repository/CollectionTagRepositoryTests.cs
+++ b/API.Tests/Repository/CollectionTagRepositoryTests.cs
@@ -114,65 +114,65 @@ public class CollectionTagRepositoryTests
#endregion
- #region RemoveTagsWithoutSeries
-
- [Fact]
- public async Task RemoveTagsWithoutSeries_ShouldRemoveTags()
- {
- var library = new LibraryBuilder("Test", LibraryType.Manga).Build();
- var series = new SeriesBuilder("Test 1").Build();
- var commonTag = new CollectionTagBuilder("Tag 1").Build();
- series.Metadata.CollectionTags.Add(commonTag);
- series.Metadata.CollectionTags.Add(new CollectionTagBuilder("Tag 2").Build());
-
- var series2 = new SeriesBuilder("Test 1").Build();
- series2.Metadata.CollectionTags.Add(commonTag);
- library.Series.Add(series);
- library.Series.Add(series2);
- _unitOfWork.LibraryRepository.Add(library);
- await _unitOfWork.CommitAsync();
-
- Assert.Equal(2, series.Metadata.CollectionTags.Count);
- Assert.Single(series2.Metadata.CollectionTags);
-
- // Delete both series
- _unitOfWork.SeriesRepository.Remove(series);
- _unitOfWork.SeriesRepository.Remove(series2);
-
- await _unitOfWork.CommitAsync();
-
- // Validate that both tags exist
- Assert.Equal(2, (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count());
-
- await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
-
- Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllTagsAsync());
- }
-
- [Fact]
- public async Task RemoveTagsWithoutSeries_ShouldNotRemoveTags()
- {
- var library = new LibraryBuilder("Test", LibraryType.Manga).Build();
- var series = new SeriesBuilder("Test 1").Build();
- var commonTag = new CollectionTagBuilder("Tag 1").Build();
- series.Metadata.CollectionTags.Add(commonTag);
- series.Metadata.CollectionTags.Add(new CollectionTagBuilder("Tag 2").Build());
-
- var series2 = new SeriesBuilder("Test 1").Build();
- series2.Metadata.CollectionTags.Add(commonTag);
- library.Series.Add(series);
- library.Series.Add(series2);
- _unitOfWork.LibraryRepository.Add(library);
- await _unitOfWork.CommitAsync();
-
- Assert.Equal(2, series.Metadata.CollectionTags.Count);
- Assert.Single(series2.Metadata.CollectionTags);
-
- await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
-
- // Validate that both tags exist
- Assert.Equal(2, (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count());
- }
-
- #endregion
+ // #region RemoveTagsWithoutSeries
+ //
+ // [Fact]
+ // public async Task RemoveTagsWithoutSeries_ShouldRemoveTags()
+ // {
+ // var library = new LibraryBuilder("Test", LibraryType.Manga).Build();
+ // var series = new SeriesBuilder("Test 1").Build();
+ // var commonTag = new AppUserCollectionBuilder("Tag 1").Build();
+ // series.Metadata.CollectionTags.Add(commonTag);
+ // series.Metadata.CollectionTags.Add(new AppUserCollectionBuilder("Tag 2").Build());
+ //
+ // var series2 = new SeriesBuilder("Test 1").Build();
+ // series2.Metadata.CollectionTags.Add(commonTag);
+ // library.Series.Add(series);
+ // library.Series.Add(series2);
+ // _unitOfWork.LibraryRepository.Add(library);
+ // await _unitOfWork.CommitAsync();
+ //
+ // Assert.Equal(2, series.Metadata.CollectionTags.Count);
+ // Assert.Single(series2.Metadata.CollectionTags);
+ //
+ // // Delete both series
+ // _unitOfWork.SeriesRepository.Remove(series);
+ // _unitOfWork.SeriesRepository.Remove(series2);
+ //
+ // await _unitOfWork.CommitAsync();
+ //
+ // // Validate that both tags exist
+ // Assert.Equal(2, (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count());
+ //
+ // await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
+ //
+ // Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllTagsAsync());
+ // }
+ //
+ // [Fact]
+ // public async Task RemoveTagsWithoutSeries_ShouldNotRemoveTags()
+ // {
+ // var library = new LibraryBuilder("Test", LibraryType.Manga).Build();
+ // var series = new SeriesBuilder("Test 1").Build();
+ // var commonTag = new AppUserCollectionBuilder("Tag 1").Build();
+ // series.Metadata.CollectionTags.Add(commonTag);
+ // series.Metadata.CollectionTags.Add(new AppUserCollectionBuilder("Tag 2").Build());
+ //
+ // var series2 = new SeriesBuilder("Test 1").Build();
+ // series2.Metadata.CollectionTags.Add(commonTag);
+ // library.Series.Add(series);
+ // library.Series.Add(series2);
+ // _unitOfWork.LibraryRepository.Add(library);
+ // await _unitOfWork.CommitAsync();
+ //
+ // Assert.Equal(2, series.Metadata.CollectionTags.Count);
+ // Assert.Single(series2.Metadata.CollectionTags);
+ //
+ // await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
+ //
+ // // Validate that both tags exist
+ // Assert.Equal(2, (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count());
+ // }
+ //
+ // #endregion
}
diff --git a/API.Tests/Services/CacheServiceTests.cs b/API.Tests/Services/CacheServiceTests.cs
index cd60ed579..ba06525a3 100644
--- a/API.Tests/Services/CacheServiceTests.cs
+++ b/API.Tests/Services/CacheServiceTests.cs
@@ -52,12 +52,12 @@ internal class MockReadingItemServiceForCacheService : IReadingItemService
throw new System.NotImplementedException();
}
- public ParserInfo Parse(string path, string rootPath, string libraryRoot, Library library)
+ public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type)
{
throw new System.NotImplementedException();
}
- public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, Library library)
+ public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type)
{
throw new System.NotImplementedException();
}
diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs
index cc00a4484..2ebee8d1d 100644
--- a/API.Tests/Services/CleanupServiceTests.cs
+++ b/API.Tests/Services/CleanupServiceTests.cs
@@ -167,53 +167,53 @@ public class CleanupServiceTests : AbstractDbTest
}
#endregion
- #region DeleteTagCoverImages
-
- [Fact]
- public async Task DeleteTagCoverImages_ShouldNotDeleteLinkedFiles()
- {
- var filesystem = CreateFileSystem();
- filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1)}.jpg", new MockFileData(""));
- filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(2)}.jpg", new MockFileData(""));
- filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1000)}.jpg", new MockFileData(""));
-
- // Delete all Series to reset state
- await ResetDb();
-
- // Add 2 series with cover images
-
- _context.Series.Add(new SeriesBuilder("Test 1")
- .WithMetadata(new SeriesMetadataBuilder()
- .WithCollectionTag(new CollectionTagBuilder("Something")
- .WithCoverImage($"{ImageService.GetCollectionTagFormat(1)}.jpg")
- .Build())
- .Build())
- .WithCoverImage($"{ImageService.GetSeriesFormat(1)}.jpg")
- .WithLibraryId(1)
- .Build());
-
- _context.Series.Add(new SeriesBuilder("Test 2")
- .WithMetadata(new SeriesMetadataBuilder()
- .WithCollectionTag(new CollectionTagBuilder("Something")
- .WithCoverImage($"{ImageService.GetCollectionTagFormat(2)}.jpg")
- .Build())
- .Build())
- .WithCoverImage($"{ImageService.GetSeriesFormat(3)}.jpg")
- .WithLibraryId(1)
- .Build());
-
-
- await _context.SaveChangesAsync();
- var ds = new DirectoryService(Substitute.For>(), filesystem);
- var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub,
- ds);
-
- await cleanupService.DeleteTagCoverImages();
-
- Assert.Equal(2, ds.GetFiles(CoverImageDirectory).Count());
- }
-
- #endregion
+ // #region DeleteTagCoverImages
+ //
+ // [Fact]
+ // public async Task DeleteTagCoverImages_ShouldNotDeleteLinkedFiles()
+ // {
+ // var filesystem = CreateFileSystem();
+ // filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1)}.jpg", new MockFileData(""));
+ // filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(2)}.jpg", new MockFileData(""));
+ // filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1000)}.jpg", new MockFileData(""));
+ //
+ // // Delete all Series to reset state
+ // await ResetDb();
+ //
+ // // Add 2 series with cover images
+ //
+ // _context.Series.Add(new SeriesBuilder("Test 1")
+ // .WithMetadata(new SeriesMetadataBuilder()
+ // .WithCollectionTag(new AppUserCollectionBuilder("Something")
+ // .WithCoverImage($"{ImageService.GetCollectionTagFormat(1)}.jpg")
+ // .Build())
+ // .Build())
+ // .WithCoverImage($"{ImageService.GetSeriesFormat(1)}.jpg")
+ // .WithLibraryId(1)
+ // .Build());
+ //
+ // _context.Series.Add(new SeriesBuilder("Test 2")
+ // .WithMetadata(new SeriesMetadataBuilder()
+ // .WithCollectionTag(new AppUserCollectionBuilder("Something")
+ // .WithCoverImage($"{ImageService.GetCollectionTagFormat(2)}.jpg")
+ // .Build())
+ // .Build())
+ // .WithCoverImage($"{ImageService.GetSeriesFormat(3)}.jpg")
+ // .WithLibraryId(1)
+ // .Build());
+ //
+ //
+ // await _context.SaveChangesAsync();
+ // var ds = new DirectoryService(Substitute.For>(), filesystem);
+ // var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub,
+ // ds);
+ //
+ // await cleanupService.DeleteTagCoverImages();
+ //
+ // Assert.Equal(2, ds.GetFiles(CoverImageDirectory).Count());
+ // }
+ //
+ // #endregion
#region DeleteReadingListCoverImages
[Fact]
@@ -435,24 +435,26 @@ public class CleanupServiceTests : AbstractDbTest
[Fact]
public async Task CleanupDbEntries_RemoveTagsWithoutSeries()
{
- var c = new CollectionTag()
+ var s = new SeriesBuilder("Test")
+ .WithFormat(MangaFormat.Epub)
+ .WithMetadata(new SeriesMetadataBuilder().Build())
+ .Build();
+ s.Library = new LibraryBuilder("Test LIb").Build();
+ _context.Series.Add(s);
+
+ var c = new AppUserCollection()
{
Title = "Test Tag",
NormalizedTitle = "Test Tag".ToNormalized(),
+ AgeRating = AgeRating.Unknown,
+ Items = new List() {s}
};
- var s = new SeriesBuilder("Test")
- .WithFormat(MangaFormat.Epub)
- .WithMetadata(new SeriesMetadataBuilder().WithCollectionTag(c).Build())
- .Build();
- s.Library = new LibraryBuilder("Test LIb").Build();
-
- _context.Series.Add(s);
_context.AppUser.Add(new AppUser()
{
- UserName = "majora2007"
+ UserName = "majora2007",
+ Collections = new List() {c}
});
-
await _context.SaveChangesAsync();
var cleanupService = new CleanupService(Substitute.For>(), _unitOfWork,
@@ -465,7 +467,7 @@ public class CleanupServiceTests : AbstractDbTest
await cleanupService.CleanupDbEntries();
- Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllTagsAsync());
+ Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllCollectionsAsync());
}
#endregion
diff --git a/API.Tests/Services/CollectionTagServiceTests.cs b/API.Tests/Services/CollectionTagServiceTests.cs
index c06767ed1..85e8391fe 100644
--- a/API.Tests/Services/CollectionTagServiceTests.cs
+++ b/API.Tests/Services/CollectionTagServiceTests.cs
@@ -3,13 +3,13 @@ using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
-using API.DTOs.CollectionTags;
+using API.DTOs.Collection;
using API.Entities;
using API.Entities.Enums;
using API.Helpers.Builders;
using API.Services;
+using API.Services.Plus;
using API.SignalR;
-using API.Tests.Helpers;
using NSubstitute;
using Xunit;
@@ -25,7 +25,7 @@ public class CollectionTagServiceTests : AbstractDbTest
protected override async Task ResetDb()
{
- _context.CollectionTag.RemoveRange(_context.CollectionTag.ToList());
+ _context.AppUserCollection.RemoveRange(_context.AppUserCollection.ToList());
_context.Library.RemoveRange(_context.Library.ToList());
await _unitOfWork.CommitAsync();
@@ -33,119 +33,148 @@ public class CollectionTagServiceTests : AbstractDbTest
private async Task SeedSeries()
{
- if (_context.CollectionTag.Any()) return;
+ if (_context.AppUserCollection.Any()) return;
+ var s1 = new SeriesBuilder("Series 1").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Mature).Build()).Build();
+ var s2 = new SeriesBuilder("Series 2").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.G).Build()).Build();
_context.Library.Add(new LibraryBuilder("Library 2", LibraryType.Manga)
- .WithSeries(new SeriesBuilder("Series 1").Build())
- .WithSeries(new SeriesBuilder("Series 2").Build())
+ .WithSeries(s1)
+ .WithSeries(s2)
.Build());
- _context.CollectionTag.Add(new CollectionTagBuilder("Tag 1").Build());
- _context.CollectionTag.Add(new CollectionTagBuilder("Tag 2").WithIsPromoted(true).Build());
+ var user = new AppUserBuilder("majora2007", "majora2007", Seed.DefaultThemes.First()).Build();
+ user.Collections = new List()
+ {
+ new AppUserCollectionBuilder("Tag 1").WithItems(new []{s1}).Build(),
+ new AppUserCollectionBuilder("Tag 2").WithItems(new []{s1, s2}).WithIsPromoted(true).Build()
+ };
+ _unitOfWork.UserRepository.Add(user);
+
await _unitOfWork.CommitAsync();
}
-
- [Fact]
- public async Task TagExistsByName_ShouldFindTag()
- {
- await SeedSeries();
- Assert.True(await _service.TagExistsByName("Tag 1"));
- Assert.True(await _service.TagExistsByName("tag 1"));
- Assert.False(await _service.TagExistsByName("tag5"));
- }
+ #region UpdateTag
[Fact]
public async Task UpdateTag_ShouldUpdateFields()
{
await SeedSeries();
- _context.CollectionTag.Add(new CollectionTagBuilder("UpdateTag_ShouldUpdateFields").WithId(3).WithIsPromoted(true).Build());
+ var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
+ Assert.NotNull(user);
+
+ user.Collections.Add(new AppUserCollectionBuilder("UpdateTag_ShouldUpdateFields").WithIsPromoted(true).Build());
+ _unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
- await _service.UpdateTag(new CollectionTagDto()
+ await _service.UpdateTag(new AppUserCollectionDto()
{
Title = "UpdateTag_ShouldUpdateFields",
Id = 3,
Promoted = true,
Summary = "Test Summary",
- });
+ AgeRating = AgeRating.Unknown
+ }, 1);
- var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(3);
+ var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(3);
Assert.NotNull(tag);
Assert.True(tag.Promoted);
- Assert.True(!string.IsNullOrEmpty(tag.Summary));
+ Assert.False(string.IsNullOrEmpty(tag.Summary));
}
+ ///
+ /// UpdateTag should not change any title if non-Kavita source
+ ///
[Fact]
- public async Task AddTagToSeries_ShouldAddTagToAllSeries()
+ public async Task UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource()
{
await SeedSeries();
- var ids = new[] {1, 2};
- await _service.AddTagToSeries(await _unitOfWork.CollectionTagRepository.GetTagAsync(1, CollectionTagIncludes.SeriesMetadata), ids);
- var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(ids);
- Assert.Contains(metadatas.ElementAt(0).CollectionTags, t => t.Title.Equals("Tag 1"));
- Assert.Contains(metadatas.ElementAt(1).CollectionTags, t => t.Title.Equals("Tag 1"));
+ var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
+ Assert.NotNull(user);
+
+ user.Collections.Add(new AppUserCollectionBuilder("UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource").WithSource(ScrobbleProvider.Mal).Build());
+ _unitOfWork.UserRepository.Update(user);
+ await _unitOfWork.CommitAsync();
+
+ await _service.UpdateTag(new AppUserCollectionDto()
+ {
+ Title = "New Title",
+ Id = 3,
+ Promoted = true,
+ Summary = "Test Summary",
+ AgeRating = AgeRating.Unknown
+ }, 1);
+
+ var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(3);
+ Assert.NotNull(tag);
+ Assert.Equal("UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource", tag.Title);
+ Assert.False(string.IsNullOrEmpty(tag.Summary));
+ }
+ #endregion
+
+
+ #region RemoveTagFromSeries
+
+ [Fact]
+ public async Task RemoveTagFromSeries_RemoveSeriesFromTag()
+ {
+ await SeedSeries();
+
+ var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
+ Assert.NotNull(user);
+
+ // Tag 2 has 2 series
+ var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2);
+ Assert.NotNull(tag);
+
+ await _service.RemoveTagFromSeries(tag, new[] {1});
+ var userCollections = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
+ Assert.Equal(2, userCollections!.Collections.Count);
+ Assert.Equal(1, tag.Items.Count);
+ Assert.Equal(2, tag.Items.First().Id);
}
+ ///
+ /// Ensure the rating of the tag updates after a series change
+ ///
[Fact]
- public async Task RemoveTagFromSeries_ShouldRemoveMultiple()
+ public async Task RemoveTagFromSeries_RemoveSeriesFromTag_UpdatesRating()
{
await SeedSeries();
- var ids = new[] {1, 2};
- var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(2, CollectionTagIncludes.SeriesMetadata);
- await _service.AddTagToSeries(tag, ids);
+
+ var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
+ Assert.NotNull(user);
+
+ // Tag 2 has 2 series
+ var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2);
+ Assert.NotNull(tag);
await _service.RemoveTagFromSeries(tag, new[] {1});
- var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {1});
-
- Assert.Single(metadatas);
- Assert.Empty(metadatas.First().CollectionTags);
- Assert.NotEmpty(await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {2}));
+ Assert.Equal(AgeRating.G, tag.AgeRating);
}
+ ///
+ /// Should remove the tag when there are no items left on the tag
+ ///
[Fact]
- public async Task GetTagOrCreate_ShouldReturnNewTag()
+ public async Task RemoveTagFromSeries_RemoveSeriesFromTag_DeleteTagWhenNoSeriesLeft()
{
await SeedSeries();
- var tag = await _service.GetTagOrCreate(0, "GetTagOrCreate_ShouldReturnNewTag");
+
+ var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
+ Assert.NotNull(user);
+
+ // Tag 1 has 1 series
+ var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1);
Assert.NotNull(tag);
- Assert.Equal(0, tag.Id);
- }
-
- [Fact]
- public async Task GetTagOrCreate_ShouldReturnExistingTag()
- {
- await SeedSeries();
- var tag = await _service.GetTagOrCreate(1, "Some new tag");
- Assert.NotNull(tag);
- Assert.Equal(1, tag.Id);
- Assert.Equal("Tag 1", tag.Title);
- }
-
- [Fact]
- public async Task RemoveTagsWithoutSeries_ShouldRemoveAbandonedEntries()
- {
- await SeedSeries();
- // Setup a tag with one series
- var tag = await _service.GetTagOrCreate(0, "Tag with a series");
- await _unitOfWork.CommitAsync();
-
- var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {1});
- tag.SeriesMetadatas.Add(metadatas.First());
- var tagId = tag.Id;
- await _unitOfWork.CommitAsync();
-
- // Validate it doesn't remove tags it shouldn't
- await _service.RemoveTagsWithoutSeries();
- Assert.NotNull(await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId));
await _service.RemoveTagFromSeries(tag, new[] {1});
-
- // Validate it does remove tags it should
- await _service.RemoveTagsWithoutSeries();
- Assert.Null(await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId));
+ var tag2 = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1);
+ Assert.Null(tag2);
}
+
+ #endregion
+
}
diff --git a/API.Tests/Services/ParseScannedFilesTests.cs b/API.Tests/Services/ParseScannedFilesTests.cs
index 03e97530c..04dc20522 100644
--- a/API.Tests/Services/ParseScannedFilesTests.cs
+++ b/API.Tests/Services/ParseScannedFilesTests.cs
@@ -54,14 +54,14 @@ internal class MockReadingItemService : IReadingItemService
throw new NotImplementedException();
}
- public ParserInfo Parse(string path, string rootPath, string libraryRoot, Library library)
+ public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type)
{
- return _defaultParser.Parse(path, rootPath, libraryRoot, library.Type);
+ return _defaultParser.Parse(path, rootPath, libraryRoot, type);
}
- public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, Library library)
+ public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type)
{
- return _defaultParser.Parse(path, rootPath, libraryRoot, library.Type);
+ return _defaultParser.Parse(path, rootPath, libraryRoot, type);
}
}
diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs
index f29bcb9b5..468c22681 100644
--- a/API.Tests/Services/ReaderServiceTests.cs
+++ b/API.Tests/Services/ReaderServiceTests.cs
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
+using API.DTOs.Progress;
using API.DTOs.Reader;
using API.Entities;
using API.Entities.Enums;
diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs
index 996358c38..0ef875e06 100644
--- a/API.Tests/Services/SeriesServiceTests.cs
+++ b/API.Tests/Services/SeriesServiceTests.cs
@@ -768,7 +768,7 @@ public class SeriesServiceTests : AbstractDbTest
SeriesId = 1,
Genres = new List {new GenreTagDto {Id = 0, Title = "New Genre"}}
},
- CollectionTags = new List()
+
});
Assert.True(success);
@@ -777,46 +777,6 @@ public class SeriesServiceTests : AbstractDbTest
Assert.NotNull(series);
Assert.NotNull(series.Metadata);
Assert.Contains("New Genre".SentenceCase(), series.Metadata.Genres.Select(g => g.Title));
-
- }
-
- [Fact]
- public async Task UpdateSeriesMetadata_ShouldCreateNewTags_IfNoneExist()
- {
- await ResetDb();
- var s = new SeriesBuilder("Test")
- .Build();
- s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build();
-
- _context.Series.Add(s);
- await _context.SaveChangesAsync();
-
- var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto
- {
- SeriesMetadata = new SeriesMetadataDto
- {
- SeriesId = 1,
- Genres = new List {new GenreTagDto {Id = 0, Title = "New Genre"}},
- Tags = new List {new TagDto {Id = 0, Title = "New Tag"}},
- Characters = new List {new PersonDto {Id = 0, Name = "Joe Shmo", Role = PersonRole.Character}},
- Colorists = new List {new PersonDto {Id = 0, Name = "Joe Shmo", Role = PersonRole.Colorist}},
- Pencillers = new List {new PersonDto {Id = 0, Name = "Joe Shmo 2", Role = PersonRole.Penciller}},
- },
- CollectionTags = new List
- {
- new CollectionTagDto {Id = 0, Promoted = false, Summary = string.Empty, CoverImageLocked = false, Title = "New Collection"}
- }
- });
-
- Assert.True(success);
-
- var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1);
- Assert.NotNull(series.Metadata);
- Assert.Contains("New Genre".SentenceCase(), series.Metadata.Genres.Select(g => g.Title));
- Assert.True(series.Metadata.People.All(g => g.Name is "Joe Shmo" or "Joe Shmo 2"));
- Assert.Contains("New Tag".SentenceCase(), series.Metadata.Tags.Select(g => g.Title));
- Assert.Contains("New Collection", series.Metadata.CollectionTags.Select(g => g.Title));
-
}
[Fact]
@@ -842,7 +802,7 @@ public class SeriesServiceTests : AbstractDbTest
SeriesId = 1,
Genres = new List {new () {Id = 0, Title = "New Genre"}},
},
- CollectionTags = new List()
+
});
Assert.True(success);
@@ -875,7 +835,7 @@ public class SeriesServiceTests : AbstractDbTest
SeriesId = 1,
Publishers = new List {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}},
},
- CollectionTags = new List()
+
});
Assert.True(success);
@@ -911,7 +871,7 @@ public class SeriesServiceTests : AbstractDbTest
Publishers = new List {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}},
PublisherLocked = true
},
- CollectionTags = new List()
+
});
Assert.True(success);
@@ -944,7 +904,7 @@ public class SeriesServiceTests : AbstractDbTest
SeriesId = 1,
Publishers = new List(),
},
- CollectionTags = new List()
+
});
Assert.True(success);
@@ -978,7 +938,7 @@ public class SeriesServiceTests : AbstractDbTest
Genres = new List {new () {Id = 1, Title = "Existing Genre"}},
GenresLocked = true
},
- CollectionTags = new List()
+
});
Assert.True(success);
@@ -1007,7 +967,7 @@ public class SeriesServiceTests : AbstractDbTest
SeriesId = 1,
ReleaseYear = 100,
},
- CollectionTags = new List()
+
});
Assert.True(success);
diff --git a/API/API.csproj b/API/API.csproj
index 42f11e012..c045d6981 100644
--- a/API/API.csproj
+++ b/API/API.csproj
@@ -66,10 +66,10 @@
-
+
-
+
@@ -81,8 +81,8 @@
-
-
+
+
@@ -95,14 +95,14 @@
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+
diff --git a/API/Constants/PolicyConstants.cs b/API/Constants/PolicyConstants.cs
index de2cf0394..1be979a56 100644
--- a/API/Constants/PolicyConstants.cs
+++ b/API/Constants/PolicyConstants.cs
@@ -40,8 +40,14 @@ public static class PolicyConstants
///
/// This is used explicitly for Demo Server. Not sure why it would be used in another fashion
public const string ReadOnlyRole = "Read Only";
+ ///
+ /// Ability to promote entities (Collections, Reading Lists, etc).
+ ///
+ public const string PromoteRole = "Promote";
+
+
public static readonly ImmutableArray ValidRoles =
- ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole, ReadOnlyRole);
+ ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole, ReadOnlyRole, PromoteRole);
}
diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs
index ab8c19d10..fc5e07378 100644
--- a/API/Controllers/AccountController.cs
+++ b/API/Controllers/AccountController.cs
@@ -363,7 +363,7 @@ public class AccountController : BaseApiController
}
// Validate no other users exist with this email
- if (user.Email!.Equals(dto.Email)) return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
+ if (user.Email!.Equals(dto.Email)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
// Check if email is used by another user
var existingUserEmail = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
@@ -386,8 +386,12 @@ public class AccountController : BaseApiController
user.ConfirmationToken = token;
await _userManager.UpdateAsync(user);
+ var emailLink = await _emailService.GenerateEmailLink(Request, user.ConfirmationToken, "confirm-email-update", dto.Email);
+ _logger.LogCritical("[Update Email]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
+
if (!shouldEmailUser)
{
+ _logger.LogInformation("Cannot email admin, email not setup or admin email invalid");
return Ok(new InviteUserResponse
{
EmailLink = string.Empty,
@@ -399,9 +403,6 @@ public class AccountController : BaseApiController
// Send a confirmation email
try
{
- var emailLink = await _emailService.GenerateEmailLink(Request, user.ConfirmationToken, "confirm-email-update", dto.Email);
- _logger.LogCritical("[Update Email]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
-
if (!_emailService.IsValidEmail(user.Email))
{
_logger.LogCritical("[Update Email]: User is trying to update their email, but their existing email ({Email}) isn't valid. No email will be send", user.Email);
@@ -839,6 +840,7 @@ public class AccountController : BaseApiController
return BadRequest(await _localizationService.Translate(user.Id, "generic-user-email-update"));
}
user.ConfirmationToken = null;
+ user.EmailConfirmed = true;
await _unitOfWork.CommitAsync();
diff --git a/API/Controllers/AdminController.cs b/API/Controllers/AdminController.cs
index 7a7d5b06a..3b9f8cdda 100644
--- a/API/Controllers/AdminController.cs
+++ b/API/Controllers/AdminController.cs
@@ -1,4 +1,6 @@
using System.Threading.Tasks;
+using API.Data.ManualMigrations;
+using API.DTOs.Progress;
using API.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
@@ -28,4 +30,15 @@ public class AdminController : BaseApiController
var users = await _userManager.GetUsersInRoleAsync("Admin");
return users.Count > 0;
}
+
+ ///
+ /// Set the progress information for a particular user
+ ///
+ ///
+ [Authorize("RequireAdminRole")]
+ [HttpPost("update-chapter-progress")]
+ public async Task> UpdateChapterProgress(UpdateUserProgressDto dto)
+ {
+ return Ok(await Task.FromResult(false));
+ }
}
diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs
index 26f6871d1..bfc6849d6 100644
--- a/API/Controllers/CollectionController.cs
+++ b/API/Controllers/CollectionController.cs
@@ -1,14 +1,18 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Threading.Tasks;
+using API.Constants;
using API.Data;
using API.Data.Repositories;
+using API.DTOs.Collection;
using API.DTOs.CollectionTags;
-using API.Entities.Metadata;
+using API.Entities;
using API.Extensions;
+using API.Helpers.Builders;
using API.Services;
+using API.Services.Plus;
using Kavita.Common;
-using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
@@ -23,61 +27,50 @@ public class CollectionController : BaseApiController
private readonly IUnitOfWork _unitOfWork;
private readonly ICollectionTagService _collectionService;
private readonly ILocalizationService _localizationService;
+ private readonly IExternalMetadataService _externalMetadataService;
///
public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService,
- ILocalizationService localizationService)
+ ILocalizationService localizationService, IExternalMetadataService externalMetadataService)
{
_unitOfWork = unitOfWork;
_collectionService = collectionService;
_localizationService = localizationService;
+ _externalMetadataService = externalMetadataService;
}
///
- /// Return a list of all collection tags on the server for the logged in user.
+ /// Returns all Collection tags for a given User
///
///
[HttpGet]
- public async Task>> GetAllTags()
+ public async Task>> GetAllTags(bool ownedOnly = false)
{
- var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
- if (user == null) return Unauthorized();
- var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
- if (isAdmin)
- {
- return Ok(await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync());
- }
-
- return Ok(await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(user.Id));
+ return Ok(await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(User.GetUserId(), !ownedOnly));
}
///
- /// Searches against the collection tags on the DB and returns matches that meet the search criteria.
- /// Search strings will be cleaned of certain fields, like %
+ /// Returns all collections that contain the Series for the user with the option to allow for promoted collections (non-user owned)
///
- /// Search term
+ ///
+ ///
///
- [Authorize(Policy = "RequireAdminRole")]
- [HttpGet("search")]
- public async Task>> SearchTags(string? queryString)
+ [HttpGet("all-series")]
+ public async Task>> GetCollectionsBySeries(int seriesId, bool ownedOnly = false)
{
- queryString ??= string.Empty;
- queryString = queryString.Replace(@"%", string.Empty);
- if (queryString.Length == 0) return await GetAllTags();
-
- return Ok(await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString, User.GetUserId()));
+ return Ok(await _unitOfWork.CollectionTagRepository.GetCollectionDtosBySeriesAsync(User.GetUserId(), seriesId, !ownedOnly));
}
+
///
/// Checks if a collection exists with the name
///
/// If empty or null, will return true as that is invalid
///
- [Authorize(Policy = "RequireAdminRole")]
[HttpGet("name-exists")]
public async Task> DoesNameExists(string name)
{
- return Ok(await _collectionService.TagExistsByName(name));
+ return Ok(await _unitOfWork.CollectionTagRepository.CollectionExists(name, User.GetUserId()));
}
///
@@ -86,13 +79,15 @@ public class CollectionController : BaseApiController
///
///
///
- [Authorize(Policy = "RequireAdminRole")]
[HttpPost("update")]
- public async Task UpdateTag(CollectionTagDto updatedTag)
+ public async Task UpdateTag(AppUserCollectionDto updatedTag)
{
try
{
- if (await _collectionService.UpdateTag(updatedTag)) return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated-successfully"));
+ if (await _collectionService.UpdateTag(updatedTag, User.GetUserId()))
+ {
+ return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated-successfully"));
+ }
}
catch (KavitaException ex)
{
@@ -103,18 +98,94 @@ public class CollectionController : BaseApiController
}
///
- /// Adds a collection tag onto multiple Series. If tag id is 0, this will create a new tag.
+ /// Promote/UnPromote multiple collections in one go. Will only update the authenticated user's collections and will only work if the user has promotion role
+ ///
+ ///
+ ///
+ [HttpPost("promote-multiple")]
+ public async Task PromoteMultipleCollections(PromoteCollectionsDto dto)
+ {
+ // This needs to take into account owner as I can select other users cards
+ var collections = await _unitOfWork.CollectionTagRepository.GetCollectionsByIds(dto.CollectionIds);
+ var userId = User.GetUserId();
+
+ if (!User.IsInRole(PolicyConstants.PromoteRole) && !User.IsInRole(PolicyConstants.AdminRole))
+ {
+ return BadRequest(await _localizationService.Translate(userId, "permission-denied"));
+ }
+
+ foreach (var collection in collections)
+ {
+ if (collection.AppUserId != userId) continue;
+ collection.Promoted = dto.Promoted;
+ _unitOfWork.CollectionTagRepository.Update(collection);
+ }
+
+ if (!_unitOfWork.HasChanges()) return Ok();
+ await _unitOfWork.CommitAsync();
+
+ return Ok();
+ }
+
+
+ ///
+ /// Promote/UnPromote multiple collections in one go
+ ///
+ ///
+ ///
+ [HttpPost("delete-multiple")]
+ public async Task DeleteMultipleCollections(PromoteCollectionsDto dto)
+ {
+ // This needs to take into account owner as I can select other users cards
+ var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections);
+ if (user == null) return Unauthorized();
+ user.Collections = user.Collections.Where(uc => !dto.CollectionIds.Contains(uc.Id)).ToList();
+ _unitOfWork.UserRepository.Update(user);
+
+
+ if (!_unitOfWork.HasChanges()) return Ok();
+ await _unitOfWork.CommitAsync();
+
+ return Ok();
+ }
+
+ ///
+ /// Adds multiple series to a collection. If tag id is 0, this will create a new tag.
///
///
///
- [Authorize(Policy = "RequireAdminRole")]
[HttpPost("update-for-series")]
public async Task AddToMultipleSeries(CollectionTagBulkAddDto dto)
{
// Create a new tag and save
- var tag = await _collectionService.GetTagOrCreate(dto.CollectionTagId, dto.CollectionTagTitle);
+ var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections);
+ if (user == null) return Unauthorized();
- if (await _collectionService.AddTagToSeries(tag, dto.SeriesIds)) return Ok();
+ AppUserCollection? tag;
+ if (dto.CollectionTagId == 0)
+ {
+ tag = new AppUserCollectionBuilder(dto.CollectionTagTitle).Build();
+ user.Collections.Add(tag);
+ }
+ else
+ {
+ // Validate tag doesn't exist
+ tag = user.Collections.FirstOrDefault(t => t.Id == dto.CollectionTagId);
+ }
+
+ if (tag == null)
+ {
+ return BadRequest(_localizationService.Translate(User.GetUserId(), "collection-doesnt-exists"));
+ }
+
+ var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(dto.SeriesIds.ToList());
+ foreach (var s in series)
+ {
+ if (tag.Items.Contains(s)) continue;
+ tag.Items.Add(s);
+ }
+ _unitOfWork.UserRepository.Update(user);
+ if (await _unitOfWork.CommitAsync()) return Ok();
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
}
@@ -124,13 +195,12 @@ public class CollectionController : BaseApiController
///
///
///
- [Authorize(Policy = "RequireAdminRole")]
[HttpPost("update-series")]
public async Task RemoveTagFromMultipleSeries(UpdateSeriesForTagDto updateSeriesForTagDto)
{
try
{
- var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(updateSeriesForTagDto.Tag.Id, CollectionTagIncludes.SeriesMetadata);
+ var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(updateSeriesForTagDto.Tag.Id, CollectionIncludes.Series);
if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist"));
if (await _collectionService.RemoveTagFromSeries(tag, updateSeriesForTagDto.SeriesIdsToRemove))
@@ -145,27 +215,42 @@ public class CollectionController : BaseApiController
}
///
- /// Removes the collection tag from all Series it was attached to
+ /// Removes the collection tag from the user
///
///
///
- [Authorize(Policy = "RequireAdminRole")]
[HttpDelete]
public async Task DeleteTag(int tagId)
{
try
{
- var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId, CollectionTagIncludes.SeriesMetadata);
- if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist"));
+ var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections);
+ if (user == null) return Unauthorized();
+ if (user.Collections.All(c => c.Id != tagId))
+ return BadRequest(await _localizationService.Translate(user.Id, "access-denied"));
- if (await _collectionService.DeleteTag(tag))
+ if (await _collectionService.DeleteTag(tagId, user))
+ {
return Ok(await _localizationService.Translate(User.GetUserId(), "collection-deleted"));
+ }
}
- catch (Exception)
+ catch (Exception ex)
{
+
await _unitOfWork.RollbackAsync();
}
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
}
+
+ ///
+ /// For the authenticated user, if they have an active Kavita+ subscription and a MAL username on record,
+ /// fetch their Mal interest stacks (including restacks)
+ ///
+ ///
+ [HttpGet("mal-stacks")]
+ public async Task>> GetMalStacksForUser()
+ {
+ return Ok(await _externalMetadataService.GetStacksForUser(User.GetUserId()));
+ }
}
diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs
index 837ad999c..b7212c7f3 100644
--- a/API/Controllers/ImageController.cs
+++ b/API/Controllers/ImageController.cs
@@ -111,7 +111,7 @@ public class ImageController : BaseApiController
}
///
- /// Returns cover image for Collection Tag
+ /// Returns cover image for Collection
///
///
///
@@ -121,6 +121,7 @@ public class ImageController : BaseApiController
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0) return BadRequest();
+
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId));
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path))
{
diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs
index b4b86dccf..eb467ab9f 100644
--- a/API/Controllers/LibraryController.cs
+++ b/API/Controllers/LibraryController.cs
@@ -166,11 +166,36 @@ public class LibraryController : BaseApiController
return Ok(_directoryService.ListDirectory(path));
}
+ ///
+ /// Return a specific library
+ ///
+ ///
+ [Authorize(Policy = "RequireAdminRole")]
+ [HttpGet]
+ public async Task> GetLibrary(int libraryId)
+ {
+ var username = User.GetUsername();
+ if (string.IsNullOrEmpty(username)) return Unauthorized();
+
+ var cacheKey = CacheKey + username;
+ var result = await _libraryCacheProvider.GetAsync>(cacheKey);
+ if (result.HasValue)
+ {
+ return Ok(result.Value.FirstOrDefault(l => l.Id == libraryId));
+ }
+
+ var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username).ToList();
+ await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24));
+ _logger.LogDebug("Caching libraries for {Key}", cacheKey);
+
+ return Ok(ret.Find(l => l.Id == libraryId));
+ }
+
///
/// Return all libraries in the Server
///
///
- [HttpGet]
+ [HttpGet("libraries")]
public async Task>> GetLibraries()
{
var username = User.GetUsername();
diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs
index 95affd2e9..c6c9fb425 100644
--- a/API/Controllers/OPDSController.cs
+++ b/API/Controllers/OPDSController.cs
@@ -9,10 +9,12 @@ using API.Comparators;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
+using API.DTOs.Collection;
using API.DTOs.CollectionTags;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.OPDS;
+using API.DTOs.Progress;
using API.DTOs.Search;
using API.Entities;
using API.Entities.Enums;
@@ -449,15 +451,13 @@ public class OpdsController : BaseApiController
var userId = await GetUser(apiKey);
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
- var (baseUrl, prefix) = await GetPrefix();
+
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user == null) return Unauthorized();
- var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
-
- var tags = isAdmin ? (await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync())
- : (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId));
+ var tags = await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(user.Id, true);
+ var (baseUrl, prefix) = await GetPrefix();
var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{prefix}{apiKey}/collections", apiKey, prefix);
SetFeedId(feed, "collections");
@@ -466,12 +466,15 @@ public class OpdsController : BaseApiController
Id = tag.Id.ToString(),
Title = tag.Title,
Summary = tag.Summary,
- Links = new List()
- {
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections/{tag.Id}"),
- CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"),
- CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}")
- }
+ Links =
+ [
+ CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
+ $"{prefix}{apiKey}/collections/{tag.Id}"),
+ CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
+ $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"),
+ CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
+ $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}")
+ ]
}));
return CreateXmlResult(SerializeXml(feed));
@@ -488,20 +491,9 @@ public class OpdsController : BaseApiController
var (baseUrl, prefix) = await GetPrefix();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user == null) return Unauthorized();
- var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
- IEnumerable tags;
- if (isAdmin)
- {
- tags = await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
- }
- else
- {
- tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId);
- }
-
- var tag = tags.SingleOrDefault(t => t.Id == collectionId);
- if (tag == null)
+ var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(collectionId);
+ if (tag == null || (tag.AppUserId != user.Id && !tag.Promoted))
{
return BadRequest("Collection does not exist or you don't have access");
}
@@ -1131,7 +1123,9 @@ public class OpdsController : BaseApiController
Id = mangaFile.Id.ToString(),
Title = title,
Extent = fileSize,
- Summary = $"{fileType.Split("/")[1]} - {fileSize}",
+ Summary = $"File Type: {fileType.Split("/")[1]} - {fileSize}" + (string.IsNullOrWhiteSpace(chapter.Summary)
+ ? string.Empty
+ : $" Summary: {chapter.Summary}"),
Format = mangaFile.Format.ToString(),
Links = new List()
{
@@ -1287,7 +1281,7 @@ public class OpdsController : BaseApiController
};
}
- private string SerializeXml(Feed feed)
+ private string SerializeXml(Feed? feed)
{
if (feed == null) return string.Empty;
using var sm = new StringWriter();
diff --git a/API/Controllers/PanelsController.cs b/API/Controllers/PanelsController.cs
index c53b68f86..d6cdbee2f 100644
--- a/API/Controllers/PanelsController.cs
+++ b/API/Controllers/PanelsController.cs
@@ -1,6 +1,7 @@
using System.Threading.Tasks;
using API.Data;
using API.DTOs;
+using API.DTOs.Progress;
using API.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs
index dd2ec1575..6e870c6ea 100644
--- a/API/Controllers/ReaderController.cs
+++ b/API/Controllers/ReaderController.cs
@@ -7,8 +7,8 @@ using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
-using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
+using API.DTOs.Progress;
using API.DTOs.Reader;
using API.Entities;
using API.Entities.Enums;
@@ -880,4 +880,21 @@ public class ReaderController : BaseApiController
await _unitOfWork.CommitAsync();
return Ok();
}
+
+ ///
+ /// Get all progress events for a given chapter
+ ///
+ ///
+ ///
+ [HttpGet("all-chapter-progress")]
+ public async Task>> GetProgressForChapter(int chapterId)
+ {
+ if (User.IsInRole(PolicyConstants.AdminRole))
+ {
+ return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId));
+ }
+
+ return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId, User.GetUserId()));
+
+ }
}
diff --git a/API/Controllers/ScrobblingController.cs b/API/Controllers/ScrobblingController.cs
index 9707bbf61..685f3e2a1 100644
--- a/API/Controllers/ScrobblingController.cs
+++ b/API/Controllers/ScrobblingController.cs
@@ -52,6 +52,23 @@ public class ScrobblingController : BaseApiController
return Ok(user.AniListAccessToken);
}
+ ///
+ /// Get the current user's MAL token & username
+ ///
+ ///
+ [HttpGet("mal-token")]
+ public async Task> GetMalToken()
+ {
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
+ if (user == null) return Unauthorized();
+
+ return Ok(new MalUserInfoDto()
+ {
+ Username = user.MalUserName,
+ AccessToken = user.MalAccessToken
+ });
+ }
+
///
/// Update the current user's AniList token
///
@@ -76,6 +93,26 @@ public class ScrobblingController : BaseApiController
return Ok();
}
+ ///
+ /// Update the current user's MAL token (Client ID) and Username
+ ///
+ ///
+ ///
+ [HttpPost("update-mal-token")]
+ public async Task UpdateMalToken(MalUserInfoDto dto)
+ {
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
+ if (user == null) return Unauthorized();
+
+ user.MalAccessToken = dto.AccessToken;
+ user.MalUserName = dto.Username;
+
+ _unitOfWork.UserRepository.Update(user);
+ await _unitOfWork.CommitAsync();
+
+ return Ok();
+ }
+
///
/// Checks if the current Scrobbling token for the given Provider has expired for the current user
///
diff --git a/API/Controllers/SearchController.cs b/API/Controllers/SearchController.cs
index 4ce7d282d..e01628dbd 100644
--- a/API/Controllers/SearchController.cs
+++ b/API/Controllers/SearchController.cs
@@ -58,7 +58,7 @@ public class SearchController : BaseApiController
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized();
var libraries = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search).ToList();
- if (!libraries.Any()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted"));
+ if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted"));
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs
index d4e1ed59b..802edebf2 100644
--- a/API/Controllers/ServerController.cs
+++ b/API/Controllers/ServerController.cs
@@ -221,18 +221,18 @@ public class ServerController : BaseApiController
///
///
[HttpGet("jobs")]
- public ActionResult> GetJobs()
+ public async Task>> GetJobs()
{
- var recurringJobs = JobStorage.Current.GetConnection().GetRecurringJobs().Select(
- dto =>
- new JobDto() {
- Id = dto.Id,
- Title = dto.Id.Replace('-', ' '),
- Cron = dto.Cron,
- LastExecutionUtc = dto.LastExecution.HasValue ? new DateTime(dto.LastExecution.Value.Ticks, DateTimeKind.Utc) : null
- });
+ var jobDtoTasks = JobStorage.Current.GetConnection().GetRecurringJobs().Select(async dto =>
+ new JobDto()
+ {
+ Id = dto.Id,
+ Title = await _localizationService.Translate(User.GetUserId(), dto.Id),
+ Cron = dto.Cron,
+ LastExecutionUtc = dto.LastExecution.HasValue ? new DateTime(dto.LastExecution.Value.Ticks, DateTimeKind.Utc) : null
+ });
- return Ok(recurringJobs);
+ return Ok(await Task.WhenAll(jobDtoTasks));
}
///
diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs
index e0339309b..e7429a9b2 100644
--- a/API/Controllers/SettingsController.cs
+++ b/API/Controllers/SettingsController.cs
@@ -457,6 +457,7 @@ public class SettingsController : BaseApiController
}
}
+
///
/// All values allowed for Task Scheduling APIs. A custom cron job is not included. Disabled is not applicable for Cleanup.
///
@@ -510,6 +511,7 @@ public class SettingsController : BaseApiController
public async Task> TestEmailServiceUrl()
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId());
+ if (string.IsNullOrEmpty(user?.Email)) return BadRequest("Your account has no email on record. Cannot email.");
return Ok(await _emailService.SendTestEmail(user!.Email));
}
}
diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs
index 9654abef6..a003551a1 100644
--- a/API/Controllers/StatsController.cs
+++ b/API/Controllers/StatsController.cs
@@ -8,6 +8,7 @@ using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
+using API.Services.Plus;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@@ -22,14 +23,16 @@ public class StatsController : BaseApiController
private readonly IUnitOfWork _unitOfWork;
private readonly UserManager _userManager;
private readonly ILocalizationService _localizationService;
+ private readonly ILicenseService _licenseService;
public StatsController(IStatisticService statService, IUnitOfWork unitOfWork,
- UserManager userManager, ILocalizationService localizationService)
+ UserManager userManager, ILocalizationService localizationService, ILicenseService licenseService)
{
_statService = statService;
_unitOfWork = unitOfWork;
_userManager = userManager;
_localizationService = localizationService;
+ _licenseService = licenseService;
}
[HttpGet("user/{userId}/read")]
@@ -181,6 +184,18 @@ public class StatsController : BaseApiController
return Ok(_statService.GetWordsReadCountByYear(userId));
}
-
+ ///
+ /// Returns for Kavita+ the number of Series that have been processed, errored, and not processed
+ ///
+ ///
+ [Authorize("RequireAdminRole")]
+ [HttpGet("kavitaplus-metadata-breakdown")]
+ [ResponseCache(CacheProfileName = "Statistics")]
+ public async Task>>> GetKavitaPlusMetadataBreakdown()
+ {
+ if (!await _licenseService.HasActiveLicense())
+ return BadRequest("This data is not available for non-Kavita+ servers");
+ return Ok(await _statService.GetKavitaPlusMetadataBreakdown());
+ }
}
diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs
index 81b3ea6fe..2430064c8 100644
--- a/API/Controllers/UploadController.cs
+++ b/API/Controllers/UploadController.cs
@@ -3,6 +3,7 @@ using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs.Uploads;
+using API.Entities.Enums;
using API.Extensions;
using API.Services;
using API.SignalR;
@@ -98,6 +99,7 @@ public class UploadController : BaseApiController
try
{
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(uploadFileDto.Id);
+
if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-doesnt-exist"));
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetSeriesFormat(uploadFileDto.Id)}");
@@ -145,7 +147,7 @@ public class UploadController : BaseApiController
try
{
- var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(uploadFileDto.Id);
+ var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(uploadFileDto.Id);
if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist"));
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}");
@@ -225,17 +227,14 @@ public class UploadController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-reading-list-save"));
}
- private async Task CreateThumbnail(UploadFileDto uploadFileDto, string filename, int thumbnailSize = 0)
+ private async Task CreateThumbnail(UploadFileDto uploadFileDto, string filename)
{
- var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
- if (thumbnailSize > 0)
- {
- return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url,
- filename, encodeFormat, thumbnailSize);
- }
+ var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
+ var encodeFormat = settings.EncodeMediaAs;
+ var coverImageSize = settings.CoverImageSize;
return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url,
- filename, encodeFormat);
+ filename, encodeFormat, coverImageSize.GetDimensions().Width);
}
///
@@ -326,8 +325,7 @@ public class UploadController : BaseApiController
try
{
var filePath = await CreateThumbnail(uploadFileDto,
- $"{ImageService.GetLibraryFormat(uploadFileDto.Id)}",
- ImageService.LibraryThumbnailWidth);
+ $"{ImageService.GetLibraryFormat(uploadFileDto.Id)}");
if (!string.IsNullOrEmpty(filePath))
{
diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs
index fdb6baa5d..1a1f37637 100644
--- a/API/Controllers/UsersController.cs
+++ b/API/Controllers/UsersController.cs
@@ -112,12 +112,23 @@ public class UsersController : BaseApiController
existingPreferences.GlobalPageLayoutMode = preferencesDto.GlobalPageLayoutMode;
existingPreferences.BlurUnreadSummaries = preferencesDto.BlurUnreadSummaries;
existingPreferences.LayoutMode = preferencesDto.LayoutMode;
- existingPreferences.Theme = preferencesDto.Theme ?? await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize;
existingPreferences.NoTransitions = preferencesDto.NoTransitions;
existingPreferences.SwipeToPaginate = preferencesDto.SwipeToPaginate;
existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships;
existingPreferences.ShareReviews = preferencesDto.ShareReviews;
+
+ existingPreferences.PdfTheme = preferencesDto.PdfTheme;
+ existingPreferences.PdfLayoutMode = preferencesDto.PdfLayoutMode;
+ existingPreferences.PdfScrollMode = preferencesDto.PdfScrollMode;
+ existingPreferences.PdfSpreadMode = preferencesDto.PdfSpreadMode;
+
+ if (existingPreferences.Theme.Id != preferencesDto.Theme?.Id)
+ {
+ existingPreferences.Theme = preferencesDto.Theme ?? await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
+ }
+
+
if (_localizationService.GetLocales().Contains(preferencesDto.Locale))
{
existingPreferences.Locale = preferencesDto.Locale;
diff --git a/API/Controllers/WantToReadController.cs b/API/Controllers/WantToReadController.cs
index b80607b56..071a027f7 100644
--- a/API/Controllers/WantToReadController.cs
+++ b/API/Controllers/WantToReadController.cs
@@ -40,6 +40,7 @@ public class WantToReadController : BaseApiController
///
/// Return all Series that are in the current logged in user's Want to Read list, filtered (deprecated, use v2)
///
+ /// This will be removed in v0.8.x
///
///
///
diff --git a/API/DTOs/Collection/AppUserCollectionDto.cs b/API/DTOs/Collection/AppUserCollectionDto.cs
new file mode 100644
index 000000000..62d786ca2
--- /dev/null
+++ b/API/DTOs/Collection/AppUserCollectionDto.cs
@@ -0,0 +1,39 @@
+using System;
+using API.Entities.Enums;
+using API.Services.Plus;
+
+namespace API.DTOs.Collection;
+#nullable enable
+
+public class AppUserCollectionDto
+{
+ public int Id { get; init; }
+ public string Title { get; set; } = default!;
+ public string Summary { get; set; } = default!;
+ public bool Promoted { get; set; }
+ public AgeRating AgeRating { get; set; }
+
+ ///
+ /// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set.
+ ///
+ public string? CoverImage { get; set; } = string.Empty;
+ public bool CoverImageLocked { get; set; }
+
+ ///
+ /// Owner of the Collection
+ ///
+ public string? Owner { get; set; }
+
+ ///
+ /// Last time Kavita Synced the Collection with an upstream source (for non Kavita sourced collections)
+ ///
+ public DateTime LastSyncUtc { get; set; }
+ ///
+ /// Who created/manages the list. Non-Kavita lists are not editable by the user, except to promote
+ ///
+ public ScrobbleProvider Source { get; set; } = ScrobbleProvider.Kavita;
+ ///
+ /// For Non-Kavita sourced collections, the url to sync from
+ ///
+ public string? SourceUrl { get; set; }
+}
diff --git a/API/DTOs/Collection/DeleteCollectionsDto.cs b/API/DTOs/Collection/DeleteCollectionsDto.cs
new file mode 100644
index 000000000..66bf257ba
--- /dev/null
+++ b/API/DTOs/Collection/DeleteCollectionsDto.cs
@@ -0,0 +1,8 @@
+using System.Collections.Generic;
+
+namespace API.DTOs.Collection;
+
+public class DeleteCollectionsDto
+{
+ public IList CollectionIds { get; set; }
+}
diff --git a/API/DTOs/Collection/MalStackDto.cs b/API/DTOs/Collection/MalStackDto.cs
new file mode 100644
index 000000000..3144f6c72
--- /dev/null
+++ b/API/DTOs/Collection/MalStackDto.cs
@@ -0,0 +1,19 @@
+namespace API.DTOs.Collection;
+
+///
+/// Represents an Interest Stack from MAL
+///
+public class MalStackDto
+{
+ public required string Title { get; set; }
+ public required long StackId { get; set; }
+ public required string Url { get; set; }
+ public required string? Author { get; set; }
+ public required int SeriesCount { get; set; }
+ public required int RestackCount { get; set; }
+ ///
+ /// If an existing collection exists within Kavita
+ ///
+ /// This is filled out from Kavita and not Kavita+
+ public int ExistingId { get; set; }
+}
diff --git a/API/DTOs/Collection/PromoteCollectionsDto.cs b/API/DTOs/Collection/PromoteCollectionsDto.cs
new file mode 100644
index 000000000..2e2ab793b
--- /dev/null
+++ b/API/DTOs/Collection/PromoteCollectionsDto.cs
@@ -0,0 +1,9 @@
+using System.Collections.Generic;
+
+namespace API.DTOs.Collection;
+
+public class PromoteCollectionsDto
+{
+ public IList CollectionIds { get; init; }
+ public bool Promoted { get; init; }
+}
diff --git a/API/DTOs/Progress/FullProgressDto.cs b/API/DTOs/Progress/FullProgressDto.cs
new file mode 100644
index 000000000..7d0b47f60
--- /dev/null
+++ b/API/DTOs/Progress/FullProgressDto.cs
@@ -0,0 +1,19 @@
+using System;
+
+namespace API.DTOs.Progress;
+
+///
+/// A full progress Record from the DB (not all data, only what's needed for API)
+///
+public class FullProgressDto
+{
+ public int Id { get; set; }
+ public int ChapterId { get; set; }
+ public int PagesRead { get; set; }
+ public DateTime LastModified { get; set; }
+ public DateTime LastModifiedUtc { get; set; }
+ public DateTime Created { get; set; }
+ public DateTime CreatedUtc { get; set; }
+ public int AppUserId { get; set; }
+ public string UserName { get; set; }
+}
diff --git a/API/DTOs/ProgressDto.cs b/API/DTOs/Progress/ProgressDto.cs
similarity index 95%
rename from API/DTOs/ProgressDto.cs
rename to API/DTOs/Progress/ProgressDto.cs
index 2a05360c4..9fc9010aa 100644
--- a/API/DTOs/ProgressDto.cs
+++ b/API/DTOs/Progress/ProgressDto.cs
@@ -1,7 +1,7 @@
using System;
using System.ComponentModel.DataAnnotations;
-namespace API.DTOs;
+namespace API.DTOs.Progress;
#nullable enable
public class ProgressDto
diff --git a/API/DTOs/Progress/UpdateUserProgressDto.cs b/API/DTOs/Progress/UpdateUserProgressDto.cs
new file mode 100644
index 000000000..2aa77b04e
--- /dev/null
+++ b/API/DTOs/Progress/UpdateUserProgressDto.cs
@@ -0,0 +1,11 @@
+using System;
+
+namespace API.DTOs.Progress;
+#nullable enable
+
+public class UpdateUserProgressDto
+{
+ public int PageNum { get; set; }
+ public DateTime LastModifiedUtc { get; set; }
+ public DateTime CreatedUtc { get; set; }
+}
diff --git a/API/DTOs/ReadingLists/ReadingListDto.cs b/API/DTOs/ReadingLists/ReadingListDto.cs
index f8791b0d6..f4961ac27 100644
--- a/API/DTOs/ReadingLists/ReadingListDto.cs
+++ b/API/DTOs/ReadingLists/ReadingListDto.cs
@@ -1,6 +1,7 @@
using System;
namespace API.DTOs.ReadingLists;
+#nullable enable
public class ReadingListDto
{
@@ -15,7 +16,7 @@ public class ReadingListDto
///
/// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set.
///
- public string CoverImage { get; set; } = string.Empty;
+ public string? CoverImage { get; set; } = string.Empty;
///
/// Minimum Year the Reading List starts
///
diff --git a/API/DTOs/Scrobbling/MalUserInfoDto.cs b/API/DTOs/Scrobbling/MalUserInfoDto.cs
new file mode 100644
index 000000000..407639e2a
--- /dev/null
+++ b/API/DTOs/Scrobbling/MalUserInfoDto.cs
@@ -0,0 +1,13 @@
+namespace API.DTOs.Scrobbling;
+
+///
+/// Information about a User's MAL connection
+///
+public class MalUserInfoDto
+{
+ public required string Username { get; set; }
+ ///
+ /// This is actually the Client Id
+ ///
+ public required string AccessToken { get; set; }
+}
diff --git a/API/DTOs/Search/SearchResultGroupDto.cs b/API/DTOs/Search/SearchResultGroupDto.cs
index eb47579f1..f7a622664 100644
--- a/API/DTOs/Search/SearchResultGroupDto.cs
+++ b/API/DTOs/Search/SearchResultGroupDto.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using API.DTOs.Collection;
using API.DTOs.CollectionTags;
using API.DTOs.Metadata;
using API.DTOs.Reader;
@@ -13,7 +14,7 @@ public class SearchResultGroupDto
{
public IEnumerable Libraries { get; set; } = default!;
public IEnumerable Series { get; set; } = default!;
- public IEnumerable Collections { get; set; } = default!;
+ public IEnumerable Collections { get; set; } = default!;
public IEnumerable ReadingLists { get; set; } = default!;
public IEnumerable Persons { get; set; } = default!;
public IEnumerable Genres { get; set; } = default!;
diff --git a/API/DTOs/SeriesMetadataDto.cs b/API/DTOs/SeriesMetadataDto.cs
index f9349bed1..3f344dff5 100644
--- a/API/DTOs/SeriesMetadataDto.cs
+++ b/API/DTOs/SeriesMetadataDto.cs
@@ -1,5 +1,4 @@
using System.Collections.Generic;
-using API.DTOs.CollectionTags;
using API.DTOs.Metadata;
using API.Entities.Enums;
@@ -10,11 +9,6 @@ public class SeriesMetadataDto
public int Id { get; set; }
public string Summary { get; set; } = string.Empty;
- ///
- /// Collections the Series belongs to
- ///
- public ICollection CollectionTags { get; set; } = new List();
-
///
/// Genres for the Series
///
diff --git a/API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs b/API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs
new file mode 100644
index 000000000..9ce44b6fa
--- /dev/null
+++ b/API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs
@@ -0,0 +1,17 @@
+namespace API.DTOs.Statistics;
+
+public class KavitaPlusMetadataBreakdownDto
+{
+ ///
+ /// Total amount of Series
+ ///
+ public int TotalSeries { get; set; }
+ ///
+ /// Series on the Blacklist (errored or bad match)
+ ///
+ public int ErroredSeries { get; set; }
+ ///
+ /// Completed so far
+ ///
+ public int SeriesCompleted { get; set; }
+}
diff --git a/API/DTOs/UpdateSeriesMetadataDto.cs b/API/DTOs/UpdateSeriesMetadataDto.cs
index 43318fe0f..719a9459a 100644
--- a/API/DTOs/UpdateSeriesMetadataDto.cs
+++ b/API/DTOs/UpdateSeriesMetadataDto.cs
@@ -1,11 +1,6 @@
-using System.Collections.Generic;
-using System.ComponentModel.DataAnnotations;
-using API.DTOs.CollectionTags;
-
-namespace API.DTOs;
+namespace API.DTOs;
public class UpdateSeriesMetadataDto
{
public SeriesMetadataDto SeriesMetadata { get; set; } = default!;
- public ICollection CollectionTags { get; set; } = default!;
}
diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs
index 41160e362..1221c73e5 100644
--- a/API/DTOs/UserPreferencesDto.cs
+++ b/API/DTOs/UserPreferencesDto.cs
@@ -152,4 +152,25 @@ public class UserPreferencesDto
///
[Required]
public string Locale { get; set; }
+
+ ///
+ /// PDF Reader: Theme of the Reader
+ ///
+ [Required]
+ public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark;
+ ///
+ /// PDF Reader: Scroll mode of the reader
+ ///
+ [Required]
+ public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical;
+ ///
+ /// PDF Reader: Layout Mode of the reader
+ ///
+ [Required]
+ public PdfLayoutMode PdfLayoutMode { get; set; } = PdfLayoutMode.Multiple;
+ ///
+ /// PDF Reader: Spread Mode of the reader
+ ///
+ [Required]
+ public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None;
}
diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs
index b4c95fe82..4af165249 100644
--- a/API/Data/DataContext.cs
+++ b/API/Data/DataContext.cs
@@ -36,6 +36,7 @@ public sealed class DataContext : IdentityDbContext ServerSetting { get; set; } = null!;
public DbSet AppUserPreferences { get; set; } = null!;
public DbSet SeriesMetadata { get; set; } = null!;
+ [Obsolete]
public DbSet CollectionTag { get; set; } = null!;
public DbSet AppUserBookmark { get; set; } = null!;
public DbSet ReadingList { get; set; } = null!;
@@ -64,6 +65,7 @@ public sealed class DataContext : IdentityDbContext ExternalRecommendation { get; set; } = null!;
public DbSet ManualMigrationHistory { get; set; } = null!;
public DbSet SeriesBlacklist { get; set; } = null!;
+ public DbSet AppUserCollection { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
@@ -149,6 +151,10 @@ public sealed class DataContext : IdentityDbContext s.ExternalSeriesMetadata)
.HasForeignKey(em => em.SeriesId)
.OnDelete(DeleteBehavior.Cascade);
+
+ builder.Entity()
+ .Property(b => b.AgeRating)
+ .HasDefaultValue(AgeRating.Unknown);
}
#nullable enable
diff --git a/API/Data/ManualMigrations/ManualMigrateLooseLeafChapters.cs b/API/Data/ManualMigrations/ManualMigrateLooseLeafChapters.cs
new file mode 100644
index 000000000..93fc569e8
--- /dev/null
+++ b/API/Data/ManualMigrations/ManualMigrateLooseLeafChapters.cs
@@ -0,0 +1,187 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using API.Entities;
+using API.Extensions;
+using API.Helpers.Builders;
+using API.Services;
+using API.Services.Tasks.Scanner.Parser;
+using Kavita.Common.EnvironmentInfo;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace API.Data.ManualMigrations;
+
+
+///
+/// v0.8.0 migration to move loose leaf chapters into their own volume and retain user progress.
+///
+public static class MigrateLooseLeafChapters
+{
+ public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, IDirectoryService directoryService, ILogger logger)
+ {
+ if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateLooseLeafChapters"))
+ {
+ return;
+ }
+
+ logger.LogCritical(
+ "Running MigrateLooseLeafChapters migration - Please be patient, this may take some time. This is not an error");
+
+ var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
+ var extension = settings.EncodeMediaAs.GetExtension();
+
+ var progress = await dataContext.AppUserProgresses
+ .Join(dataContext.Chapter, p => p.ChapterId, c => c.Id, (p, c) => new UserProgressCsvRecord
+ {
+ IsSpecial = c.IsSpecial,
+ AppUserId = p.AppUserId,
+ PagesRead = p.PagesRead,
+ Range = c.Range,
+ Number = c.Number,
+ MinNumber = c.MinNumber,
+ SeriesId = p.SeriesId,
+ VolumeId = p.VolumeId,
+ ProgressId = p.Id
+ })
+ .Where(d => !d.IsSpecial)
+ .Join(dataContext.Volume, d => d.VolumeId, v => v.Id, (d, v) => new
+ {
+ ProgressRecord = d,
+ Volume = v
+ })
+ .Where(d => d.Volume.Name == "0")
+ .ToListAsync();
+
+ // First, group all the progresses into different series
+ logger.LogCritical("Migrating {Count} progress events to new Volume structure for Loose leafs - This may take over 10 minutes depending on size of DB. Please wait", progress.Count);
+ var progressesGroupedBySeries = progress
+ .GroupBy(p => p.ProgressRecord.SeriesId);
+
+ foreach (var seriesGroup in progressesGroupedBySeries)
+ {
+ // Get each series and move the loose leafs from the old volume to the new Volume
+ var seriesId = seriesGroup.Key;
+
+ // Handle All Loose Leafs
+ var looseLeafsInSeries = seriesGroup
+ .Where(p => !p.ProgressRecord.IsSpecial)
+ .ToList();
+
+ // Get distinct Volumes by Id. For each one, create it then create the progress events
+ var distinctVolumes = looseLeafsInSeries.DistinctBy(d => d.Volume.Id);
+ foreach (var distinctVolume in distinctVolumes)
+ {
+ // Create a new volume for each series with the appropriate number (-100000)
+ var chapters = await dataContext.Chapter
+ .Where(c => c.VolumeId == distinctVolume.Volume.Id && !c.IsSpecial).ToListAsync();
+
+ var newVolume = new VolumeBuilder(Parser.LooseLeafVolume)
+ .WithSeriesId(seriesId)
+ .WithCreated(distinctVolume.Volume.Created)
+ .WithLastModified(distinctVolume.Volume.LastModified)
+ .Build();
+
+ newVolume.Pages = chapters.Sum(c => c.Pages);
+ newVolume.WordCount = chapters.Sum(c => c.WordCount);
+ newVolume.MinHoursToRead = chapters.Sum(c => c.MinHoursToRead);
+ newVolume.MaxHoursToRead = chapters.Sum(c => c.MaxHoursToRead);
+ newVolume.AvgHoursToRead = chapters.Sum(c => c.AvgHoursToRead);
+ dataContext.Volume.Add(newVolume);
+ await dataContext.SaveChangesAsync(); // Save changes to generate the newVolumeId
+
+ // Migrate the progress event to the new volume
+ var oldVolumeProgresses = await dataContext.AppUserProgresses
+ .Where(p => p.VolumeId == distinctVolume.Volume.Id).ToListAsync();
+ foreach (var oldProgress in oldVolumeProgresses)
+ {
+ oldProgress.VolumeId = newVolume.Id;
+ }
+
+
+ logger.LogInformation("Moving {Count} chapters from Volume Id {OldVolumeId} to New Volume {NewVolumeId}",
+ chapters.Count, distinctVolume.Volume.Id, newVolume.Id);
+
+ // Move the loose leaf chapters from the old volume to the new Volume
+ foreach (var chapter in chapters)
+ {
+ // Update the VolumeId on the existing progress event
+ chapter.VolumeId = newVolume.Id;
+
+ // We need to migrate cover images as well
+ //UpdateCoverImage(directoryService, logger, chapter, extension, newVolume);
+ }
+
+
+ var oldVolumeBookmarks = await dataContext.AppUserBookmark
+ .Where(p => p.VolumeId == distinctVolume.Volume.Id).ToListAsync();
+ logger.LogInformation("Moving {Count} existing Bookmarks from Volume Id {OldVolumeId} to New Volume {NewVolumeId}",
+ oldVolumeBookmarks.Count, distinctVolume.Volume.Id, newVolume.Id);
+ foreach (var bookmark in oldVolumeBookmarks)
+ {
+ bookmark.VolumeId = newVolume.Id;
+ }
+
+
+ var oldVolumePersonalToC = await dataContext.AppUserTableOfContent
+ .Where(p => p.VolumeId == distinctVolume.Volume.Id).ToListAsync();
+ logger.LogInformation("Moving {Count} existing Personal ToC from Volume Id {OldVolumeId} to New Volume {NewVolumeId}",
+ oldVolumePersonalToC.Count, distinctVolume.Volume.Id, newVolume.Id);
+ foreach (var pToc in oldVolumePersonalToC)
+ {
+ pToc.VolumeId = newVolume.Id;
+ }
+
+ var oldVolumeReadingListItems = await dataContext.ReadingListItem
+ .Where(p => p.VolumeId == distinctVolume.Volume.Id).ToListAsync();
+ logger.LogInformation("Moving {Count} existing Personal ToC from Volume Id {OldVolumeId} to New Volume {NewVolumeId}",
+ oldVolumeReadingListItems.Count, distinctVolume.Volume.Id, newVolume.Id);
+ foreach (var readingListItem in oldVolumeReadingListItems)
+ {
+ readingListItem.VolumeId = newVolume.Id;
+ }
+
+
+ await dataContext.SaveChangesAsync();
+ }
+ }
+
+ // Save changes after processing all series
+ if (dataContext.ChangeTracker.HasChanges())
+ {
+ await dataContext.SaveChangesAsync();
+ }
+
+
+ dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
+ {
+ Name = "MigrateLooseLeafChapters",
+ ProductVersion = BuildInfo.Version.ToString(),
+ RanAt = DateTime.UtcNow
+ });
+ await dataContext.SaveChangesAsync();
+
+ logger.LogCritical(
+ "Running MigrateLooseLeafChapters migration - Completed. This is not an error");
+ }
+
+ private static void UpdateCoverImage(IDirectoryService directoryService, ILogger logger, Chapter chapter,
+ string extension, Volume newVolume)
+ {
+ var existingCover = ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId) + extension;
+ var newCover = ImageService.GetChapterFormat(chapter.Id, newVolume.Id) + extension;
+ try
+ {
+ if (!chapter.CoverImageLocked)
+ {
+ // First rename existing cover
+ File.Copy(Path.Join(directoryService.CoverImageDirectory, existingCover), Path.Join(directoryService.CoverImageDirectory, newCover));
+ chapter.CoverImage = newCover;
+ }
+ } catch (Exception ex)
+ {
+ logger.LogError(ex, "Unable to rename {OldCover} to {NewCover}, this cover will need manual refresh", existingCover, newCover);
+ }
+ }
+}
diff --git a/API/Data/ManualMigrations/ManualMigrateMixedSpecials.cs b/API/Data/ManualMigrations/ManualMigrateMixedSpecials.cs
index c9c173eea..4e22abfb8 100644
--- a/API/Data/ManualMigrations/ManualMigrateMixedSpecials.cs
+++ b/API/Data/ManualMigrations/ManualMigrateMixedSpecials.cs
@@ -3,7 +3,9 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Entities;
+using API.Extensions;
using API.Helpers.Builders;
+using API.Services;
using API.Services.Tasks.Scanner.Parser;
using Kavita.Common.EnvironmentInfo;
using Microsoft.EntityFrameworkCore;
@@ -21,6 +23,7 @@ public class UserProgressCsvRecord
public float MinNumber { get; set; }
public int SeriesId { get; set; }
public int VolumeId { get; set; }
+ public int ProgressId { get; set; }
}
///
@@ -28,7 +31,7 @@ public class UserProgressCsvRecord
///
public static class MigrateMixedSpecials
{
- public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, ILogger logger)
+ public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, IDirectoryService directoryService, ILogger logger)
{
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateMixedSpecials"))
{
@@ -39,13 +42,13 @@ public static class MigrateMixedSpecials
"Running ManualMigrateMixedSpecials migration - Please be patient, this may take some time. This is not an error");
// First, group all the progresses into different series
-
// Get each series and move the specials from old volume to the new Volume()
-
// Create a new progress event from existing and store the Id of existing progress event to delete it
-
// Save per series
+ var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
+ var extension = settings.EncodeMediaAs.GetExtension();
+
var progress = await dataContext.AppUserProgresses
.Join(dataContext.Chapter, p => p.ChapterId, c => c.Id, (p, c) => new UserProgressCsvRecord
{
@@ -56,10 +59,12 @@ public static class MigrateMixedSpecials
Number = c.Number,
MinNumber = c.MinNumber,
SeriesId = p.SeriesId,
- VolumeId = p.VolumeId
+ VolumeId = p.VolumeId,
+ ProgressId = p.Id
})
.Where(d => d.IsSpecial || d.Number == "0")
- .Join(dataContext.Volume, d => d.VolumeId, v => v.Id, (d, v) => new
+ .Join(dataContext.Volume, d => d.VolumeId, v => v.Id,
+ (d, v) => new
{
ProgressRecord = d,
Volume = v
@@ -68,18 +73,19 @@ public static class MigrateMixedSpecials
.ToListAsync();
// First, group all the progresses into different series
- logger.LogCritical("Migrating {Count} progress events to new Volume structure - This may take over 10 minutes depending on size of DB. Please wait", progress.Count);
+ logger.LogCritical("Migrating {Count} progress events to new Volume structure for Specials - This may take over 10 minutes depending on size of DB. Please wait", progress.Count);
var progressesGroupedBySeries = progress.GroupBy(p => p.ProgressRecord.SeriesId);
foreach (var seriesGroup in progressesGroupedBySeries)
{
// Get each series and move the specials from the old volume to the new Volume
var seriesId = seriesGroup.Key;
+
+ // Handle All Specials
var specialsInSeries = seriesGroup
.Where(p => p.ProgressRecord.IsSpecial)
.ToList();
-
// Get distinct Volumes by Id. For each one, create it then create the progress events
var distinctVolumes = specialsInSeries.DistinctBy(d => d.Volume.Id);
foreach (var distinctVolume in distinctVolumes)
@@ -90,29 +96,72 @@ public static class MigrateMixedSpecials
var newVolume = new VolumeBuilder(Parser.SpecialVolume)
.WithSeriesId(seriesId)
- .WithChapters(chapters)
+ .WithCreated(distinctVolume.Volume.Created)
+ .WithLastModified(distinctVolume.Volume.LastModified)
.Build();
+
+ newVolume.Pages = chapters.Sum(c => c.Pages);
+ newVolume.WordCount = chapters.Sum(c => c.WordCount);
+ newVolume.MinHoursToRead = chapters.Sum(c => c.MinHoursToRead);
+ newVolume.MaxHoursToRead = chapters.Sum(c => c.MaxHoursToRead);
+ newVolume.AvgHoursToRead = chapters.Sum(c => c.AvgHoursToRead);
+
dataContext.Volume.Add(newVolume);
await dataContext.SaveChangesAsync(); // Save changes to generate the newVolumeId
// Migrate the progress event to the new volume
- distinctVolume.ProgressRecord.VolumeId = newVolume.Id;
+ var oldVolumeProgresses = await dataContext.AppUserProgresses
+ .Where(p => p.VolumeId == distinctVolume.Volume.Id).ToListAsync();
+ foreach (var oldProgress in oldVolumeProgresses)
+ {
+ oldProgress.VolumeId = newVolume.Id;
+ }
logger.LogInformation("Moving {Count} chapters from Volume Id {OldVolumeId} to New Volume {NewVolumeId}",
chapters.Count, distinctVolume.Volume.Id, newVolume.Id);
- // Move the special chapters from the old volume to the new Volume
- var specialChapters = await dataContext.Chapter
- .Where(c => c.VolumeId == distinctVolume.ProgressRecord.VolumeId && c.IsSpecial)
- .ToListAsync();
- foreach (var specialChapter in specialChapters)
+ // Move the special chapters from the old volume to the new Volume
+ foreach (var specialChapter in chapters)
{
// Update the VolumeId on the existing progress event
specialChapter.VolumeId = newVolume.Id;
+
+ //UpdateCoverImage(directoryService, logger, specialChapter, extension, newVolume);
}
+
+ var oldVolumeBookmarks = await dataContext.AppUserBookmark
+ .Where(p => p.VolumeId == distinctVolume.Volume.Id).ToListAsync();
+ logger.LogInformation("Moving {Count} existing Bookmarks from Volume Id {OldVolumeId} to New Volume {NewVolumeId}",
+ oldVolumeBookmarks.Count, distinctVolume.Volume.Id, newVolume.Id);
+ foreach (var bookmark in oldVolumeBookmarks)
+ {
+ bookmark.VolumeId = newVolume.Id;
+ }
+
+
+ var oldVolumePersonalToC = await dataContext.AppUserTableOfContent
+ .Where(p => p.VolumeId == distinctVolume.Volume.Id).ToListAsync();
+ logger.LogInformation("Moving {Count} existing Personal ToC from Volume Id {OldVolumeId} to New Volume {NewVolumeId}",
+ oldVolumePersonalToC.Count, distinctVolume.Volume.Id, newVolume.Id);
+ foreach (var pToc in oldVolumePersonalToC)
+ {
+ pToc.VolumeId = newVolume.Id;
+ }
+
+ var oldVolumeReadingListItems = await dataContext.ReadingListItem
+ .Where(p => p.VolumeId == distinctVolume.Volume.Id).ToListAsync();
+ logger.LogInformation("Moving {Count} existing Personal ToC from Volume Id {OldVolumeId} to New Volume {NewVolumeId}",
+ oldVolumeReadingListItems.Count, distinctVolume.Volume.Id, newVolume.Id);
+ foreach (var readingListItem in oldVolumeReadingListItems)
+ {
+ readingListItem.VolumeId = newVolume.Id;
+ }
+
await dataContext.SaveChangesAsync();
}
+
+
}
// Save changes after processing all series
@@ -121,10 +170,6 @@ public static class MigrateMixedSpecials
await dataContext.SaveChangesAsync();
}
- // Update all Volumes with Name as "0" -> Special
- logger.LogCritical("Updating all Volumes with Name 0 to SpecialNumber");
-
-
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
{
@@ -137,4 +182,25 @@ public static class MigrateMixedSpecials
logger.LogCritical(
"Running ManualMigrateMixedSpecials migration - Completed. This is not an error");
}
+
+ private static void UpdateCoverImage(IDirectoryService directoryService, ILogger logger, Chapter specialChapter,
+ string extension, Volume newVolume)
+ {
+ // We need to migrate cover images as well
+ var existingCover = ImageService.GetChapterFormat(specialChapter.Id, specialChapter.VolumeId) + extension;
+ var newCover = ImageService.GetChapterFormat(specialChapter.Id, newVolume.Id) + extension;
+ try
+ {
+
+ if (!specialChapter.CoverImageLocked)
+ {
+ // First rename existing cover
+ File.Copy(Path.Join(directoryService.CoverImageDirectory, existingCover), Path.Join(directoryService.CoverImageDirectory, newCover));
+ specialChapter.CoverImage = newCover;
+ }
+ } catch (Exception ex)
+ {
+ logger.LogError(ex, "Unable to rename {OldCover} to {NewCover}, this cover will need manual refresh", existingCover, newCover);
+ }
+ }
}
diff --git a/API/Data/ManualMigrations/MigrateCollectionTagToUserCollections.cs b/API/Data/ManualMigrations/MigrateCollectionTagToUserCollections.cs
new file mode 100644
index 000000000..7204bd0d3
--- /dev/null
+++ b/API/Data/ManualMigrations/MigrateCollectionTagToUserCollections.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using API.Data.Repositories;
+using API.Entities;
+using API.Entities.Enums;
+using API.Extensions.QueryExtensions;
+using Kavita.Common.EnvironmentInfo;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace API.Data.ManualMigrations;
+
+///
+/// v0.8.0 refactored User Collections
+///
+public static class MigrateCollectionTagToUserCollections
+{
+ public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, ILogger logger)
+ {
+ if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateCollectionTagToUserCollections"))
+ {
+ return;
+ }
+
+ logger.LogCritical(
+ "Running MigrateCollectionTagToUserCollections migration - Please be patient, this may take some time. This is not an error");
+
+ // Find the first user that is an admin
+ var defaultAdmin = await unitOfWork.UserRepository.GetDefaultAdminUser(AppUserIncludes.Collections);
+ if (defaultAdmin == null)
+ {
+ await CompleteMigration(dataContext, logger);
+ return;
+ }
+
+ // For all collectionTags, move them over to said user
+ var existingCollections = await dataContext.CollectionTag
+ .OrderBy(c => c.NormalizedTitle)
+ .Includes(CollectionTagIncludes.SeriesMetadataWithSeries)
+ .ToListAsync();
+ foreach (var existingCollectionTag in existingCollections)
+ {
+ var collection = new AppUserCollection()
+ {
+ Title = existingCollectionTag.Title,
+ NormalizedTitle = existingCollectionTag.Title.Normalize(),
+ CoverImage = existingCollectionTag.CoverImage,
+ CoverImageLocked = existingCollectionTag.CoverImageLocked,
+ Promoted = existingCollectionTag.Promoted,
+ AgeRating = AgeRating.Unknown,
+ Summary = existingCollectionTag.Summary,
+ Items = existingCollectionTag.SeriesMetadatas.Select(s => s.Series).ToList()
+ };
+
+ collection.AgeRating = await unitOfWork.SeriesRepository.GetMaxAgeRatingFromSeriesAsync(collection.Items.Select(s => s.Id));
+ defaultAdmin.Collections.Add(collection);
+ }
+ unitOfWork.UserRepository.Update(defaultAdmin);
+
+ await unitOfWork.CommitAsync();
+
+ await CompleteMigration(dataContext, logger);
+ }
+
+ private static async Task CompleteMigration(DataContext dataContext, ILogger logger)
+ {
+ dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
+ {
+ Name = "MigrateCollectionTagToUserCollections",
+ ProductVersion = BuildInfo.Version.ToString(),
+ RanAt = DateTime.UtcNow
+ });
+
+ await dataContext.SaveChangesAsync();
+
+ logger.LogCritical(
+ "Running MigrateCollectionTagToUserCollections migration - Completed. This is not an error");
+ }
+}
diff --git a/API/Data/ManualMigrations/MigrateMangaFilePath.cs b/API/Data/ManualMigrations/MigrateMangaFilePath.cs
new file mode 100644
index 000000000..ccf9aa773
--- /dev/null
+++ b/API/Data/ManualMigrations/MigrateMangaFilePath.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Threading.Tasks;
+using API.Entities;
+using API.Services.Tasks.Scanner.Parser;
+using Kavita.Common.EnvironmentInfo;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace API.Data.ManualMigrations;
+
+///
+/// v0.8.0 ensured that MangaFile Path is normalized. This will normalize existing data to avoid churn.
+///
+public static class MigrateMangaFilePath
+{
+ public static async Task Migrate(DataContext dataContext, ILogger logger)
+ {
+ if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateMangaFilePath"))
+ {
+ return;
+ }
+
+ logger.LogCritical(
+ "Running MigrateMangaFilePath migration - Please be patient, this may take some time. This is not an error");
+
+
+ foreach(var file in dataContext.MangaFile)
+ {
+ file.FilePath = Parser.NormalizePath(file.FilePath);
+ }
+
+ await dataContext.SaveChangesAsync();
+
+ dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
+ {
+ Name = "MigrateMangaFilePath",
+ ProductVersion = BuildInfo.Version.ToString(),
+ RanAt = DateTime.UtcNow
+ });
+ await dataContext.SaveChangesAsync();
+
+ logger.LogCritical(
+ "Running MigrateMangaFilePath migration - Completed. This is not an error");
+ }
+}
diff --git a/API/Data/ManualMigrations/MigrateProgressExport.cs b/API/Data/ManualMigrations/MigrateProgressExport.cs
new file mode 100644
index 000000000..2482939c0
--- /dev/null
+++ b/API/Data/ManualMigrations/MigrateProgressExport.cs
@@ -0,0 +1,123 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using API.Entities;
+using API.Services;
+using CsvHelper;
+using CsvHelper.Configuration.Attributes;
+using Kavita.Common.EnvironmentInfo;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace API.Data.ManualMigrations;
+
+public class ProgressExport
+{
+ [Name("Library Id")]
+ public int LibraryId { get; set; }
+
+ [Name("Library Name")]
+ public string LibraryName { get; set; }
+
+ [Name("Series Name")]
+ public string SeriesName { get; set; }
+
+ [Name("Volume Number")]
+ public string VolumeRange { get; set; }
+
+ [Name("Volume LookupName")]
+ public string VolumeLookupName { get; set; }
+
+ [Name("Chapter Number")]
+ public string ChapterRange { get; set; }
+
+ [Name("FileName")]
+ public string MangaFileName { get; set; }
+
+ [Name("FilePath")]
+ public string MangaFilePath { get; set; }
+
+ [Name("AppUser Name")]
+ public string AppUserName { get; set; }
+
+ [Name("AppUser Id")]
+ public int AppUserId { get; set; }
+
+ [Name("Pages Read")]
+ public int PagesRead { get; set; }
+
+ [Name("BookScrollId")]
+ public string BookScrollId { get; set; }
+
+ [Name("Progress Created")]
+ public DateTime Created { get; set; }
+
+ [Name("Progress LastModified")]
+ public DateTime LastModified { get; set; }
+}
+
+///
+/// v0.8.0 - Progress is extracted and saved in a csv
+///
+public static class MigrateProgressExport
+{
+ public static async Task Migrate(DataContext dataContext, IDirectoryService directoryService, ILogger logger)
+ {
+ try
+ {
+ if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateProgressExport"))
+ {
+ return;
+ }
+
+ logger.LogCritical(
+ "Running MigrateProgressExport migration - Please be patient, this may take some time. This is not an error");
+
+ var data = await dataContext.AppUserProgresses
+ .Join(dataContext.Series, progress => progress.SeriesId, series => series.Id, (progress, series) => new { progress, series })
+ .Join(dataContext.Volume, ps => ps.progress.VolumeId, volume => volume.Id, (ps, volume) => new { ps.progress, ps.series, volume })
+ .Join(dataContext.Chapter, psv => psv.progress.ChapterId, chapter => chapter.Id, (psv, chapter) => new { psv.progress, psv.series, psv.volume, chapter })
+ .Join(dataContext.MangaFile, psvc => psvc.chapter.Id, mangaFile => mangaFile.ChapterId, (psvc, mangaFile) => new { psvc.progress, psvc.series, psvc.volume, psvc.chapter, mangaFile })
+ .Join(dataContext.AppUser, psvcm => psvcm.progress.AppUserId, appUser => appUser.Id, (psvcm, appUser) => new
+ {
+ LibraryId = psvcm.series.LibraryId,
+ LibraryName = psvcm.series.Library.Name,
+ SeriesName = psvcm.series.Name,
+ VolumeRange = psvcm.volume.MinNumber + "-" + psvcm.volume.MaxNumber,
+ VolumeLookupName = psvcm.volume.Name,
+ ChapterRange = psvcm.chapter.Range,
+ MangaFileName = psvcm.mangaFile.FileName,
+ MangaFilePath = psvcm.mangaFile.FilePath,
+ AppUserName = appUser.UserName,
+ AppUserId = appUser.Id,
+ PagesRead = psvcm.progress.PagesRead,
+ BookScrollId = psvcm.progress.BookScrollId,
+ ProgressCreated = psvcm.progress.Created,
+ ProgressLastModified = psvcm.progress.LastModified
+ }).ToListAsync();
+
+
+ // Write the mapped data to a CSV file
+ await using var writer = new StreamWriter(Path.Join(directoryService.ConfigDirectory, "progress_export.csv"));
+ await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
+ await csv.WriteRecordsAsync(data);
+
+ logger.LogCritical(
+ "Running MigrateProgressExport migration - Completed. This is not an error");
+ }
+ catch (Exception ex)
+ {
+ // On new installs, the db isn't setup yet, so this has nothing to do
+ }
+
+ dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
+ {
+ Name = "MigrateProgressExport",
+ ProductVersion = BuildInfo.Version.ToString(),
+ RanAt = DateTime.UtcNow
+ });
+ await dataContext.SaveChangesAsync();
+ }
+}
diff --git a/API/Data/ManualMigrations/MigrateWantToReadExport.cs b/API/Data/ManualMigrations/MigrateWantToReadExport.cs
index eb788f1e8..95a86c370 100644
--- a/API/Data/ManualMigrations/MigrateWantToReadExport.cs
+++ b/API/Data/ManualMigrations/MigrateWantToReadExport.cs
@@ -20,6 +20,7 @@ public static class MigrateWantToReadExport
{
try
{
+
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateWantToReadExport"))
{
return;
diff --git a/API/Data/Migrations/20240321173812_UserMalToken.Designer.cs b/API/Data/Migrations/20240321173812_UserMalToken.Designer.cs
new file mode 100644
index 000000000..ee182676d
--- /dev/null
+++ b/API/Data/Migrations/20240321173812_UserMalToken.Designer.cs
@@ -0,0 +1,2904 @@
+//
+using System;
+using API.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace API.Data.Migrations
+{
+ [DbContext(typeof(DataContext))]
+ [Migration("20240321173812_UserMalToken")]
+ partial class UserMalToken
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "8.0.3");
+
+ modelBuilder.Entity("API.Entities.AppRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("API.Entities.AppUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRestriction")
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRestrictionIncludeUnknowns")
+ .HasColumnType("INTEGER");
+
+ b.Property("AniListAccessToken")
+ .HasColumnType("TEXT");
+
+ b.Property("ApiKey")
+ .HasColumnType("TEXT");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("ConfirmationToken")
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastActive")
+ .HasColumnType("TEXT");
+
+ b.Property("LastActiveUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("TEXT");
+
+ b.Property("MalAccessToken")
+ .HasColumnType("TEXT");
+
+ b.Property("MalUserName")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("TEXT");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("FileName")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Page")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserBookmark");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserDashboardStream", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsProvided")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property("SmartFilterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("StreamType")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(4);
+
+ b.Property("Visible")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SmartFilterId");
+
+ b.HasIndex("Visible");
+
+ b.ToTable("AppUserDashboardStream");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserExternalSource", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ApiKey")
+ .HasColumnType("TEXT");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Host")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserExternalSource");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserOnDeckRemoval");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("AutoCloseMenu")
+ .HasColumnType("INTEGER");
+
+ b.Property("BackgroundColor")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("#000000");
+
+ b.Property("BlurUnreadSummaries")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderFontFamily")
+ .HasColumnType("TEXT");
+
+ b.Property("BookReaderFontSize")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderImmersiveMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderLayoutMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderLineSpacing")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderMargin")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderTapToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderWritingStyle")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("BookThemeName")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("Dark");
+
+ b.Property("CollapseSeriesRelationships")
+ .HasColumnType("INTEGER");
+
+ b.Property("EmulateBook")
+ .HasColumnType("INTEGER");
+
+ b.Property("GlobalPageLayoutMode")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("LayoutMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("Locale")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("en");
+
+ b.Property("NoTransitions")
+ .HasColumnType("INTEGER");
+
+ b.Property("PageSplitOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("PromptForDownloadSize")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReaderMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("ScalingOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShareReviews")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowScreenHints")
+ .HasColumnType("INTEGER");
+
+ b.Property("SwipeToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("ThemeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId")
+ .IsUnique();
+
+ b.HasIndex("ThemeId");
+
+ b.ToTable("AppUserPreferences");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserProgress", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookScrollId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PagesRead")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("ChapterId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserProgresses");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserRating", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("HasBeenRated")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("REAL");
+
+ b.Property("Review")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Tagline")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserRating");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RoleId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserSideNavStream", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ExternalSourceId")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsProvided")
+ .HasColumnType("INTEGER");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property("SmartFilterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("StreamType")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(5);
+
+ b.Property("Visible")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SmartFilterId");
+
+ b.HasIndex("Visible");
+
+ b.ToTable("AppUserSideNavStream");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserSmartFilter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Filter")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserSmartFilter");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserTableOfContent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookScrollId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PageNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .HasColumnType("TEXT");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("ChapterId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserTableOfContent");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserWantToRead", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserWantToRead");
+ });
+
+ modelBuilder.Entity("API.Entities.Chapter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRating")
+ .HasColumnType("INTEGER");
+
+ b.Property("AlternateCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("AlternateNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("AlternateSeries")
+ .HasColumnType("TEXT");
+
+ b.Property("AvgHoursToRead")
+ .HasColumnType("INTEGER");
+
+ b.Property("Count")
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverImage")
+ .HasColumnType("TEXT");
+
+ b.Property("CoverImageLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("ISBN")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("");
+
+ b.Property("IsSpecial")
+ .HasColumnType("INTEGER");
+
+ b.Property("Language")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("MaxHoursToRead")
+ .HasColumnType("INTEGER");
+
+ b.Property("MaxNumber")
+ .HasColumnType("REAL");
+
+ b.Property("MinHoursToRead")
+ .HasColumnType("INTEGER");
+
+ b.Property("MinNumber")
+ .HasColumnType("REAL");
+
+ b.Property("Number")
+ .HasColumnType("TEXT");
+
+ b.Property("Pages")
+ .HasColumnType("INTEGER");
+
+ b.Property("Range")
+ .HasColumnType("TEXT");
+
+ b.Property("ReleaseDate")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesGroup")
+ .HasColumnType("TEXT");
+
+ b.Property("SortOrder")
+ .HasColumnType("REAL");
+
+ b.Property("StoryArc")
+ .HasColumnType("TEXT");
+
+ b.Property("StoryArcNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("Summary")
+ .HasColumnType("TEXT");
+
+ b.Property