diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 000000000..f2a39131a
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,38 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: bug
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Desktop (please complete the following information):**
+ - OS: [e.g. iOS]
+ - Browser [e.g. chrome, safari]
+ - Version [e.g. 22] (can be found on Server Settings -> System tab)
+
+**Smartphone (please complete the following information):**
+ - Device: [e.g. iPhone6]
+ - OS: [e.g. iOS8.1]
+ - Browser [e.g. stock browser, safari]
+ - Version [e.g. 22]
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/workflows/nightly-docker.yml b/.github/workflows/nightly-docker.yml
deleted file mode 100644
index 680015afe..000000000
--- a/.github/workflows/nightly-docker.yml
+++ /dev/null
@@ -1,88 +0,0 @@
-name: Build Nightly Docker
-
-on:
- push:
- branches:
- - 'develop'
-
-jobs:
- docker:
- name: Building Nightly Docker
- runs-on: ubuntu-latest
- steps:
-
- - name: Check Out Repo
- uses: actions/checkout@v2
-
- - name: NodeJS to Compile WebUI
- uses: actions/setup-node@v2.1.5
- with:
- node-version: '14'
- - run: |
- cd UI/Web || exit
- echo 'Installing web dependencies'
- npm install
-
- echo 'Building UI'
- npm run prod
-
- echo 'Copying back to Kavita wwwroot'
- rsync -a dist/ ../../API/wwwroot/
-
- cd ../ || exit
-
- - name: Get csproj Version
- uses: naminodarie/get-net-sdk-project-versions-action@v1
- id: get-version
- with:
- proj-path: Kavita.Common/Kavita.Common.csproj
-
- - name: Echo csproj version
- run: echo "${{steps.get-version.outputs.assembly-version}}"
-
- - name: Compile dotnet app
- uses: actions/setup-dotnet@v1
- with:
- dotnet-version: '5.0.x'
- - run: ./monorepo-build.sh
-
- - name: Trigger Sentry workflow
- uses: benc-uk/workflow-dispatch@v1
- with:
- workflow: Sentry Map Release
- token: ${{ secrets.REPO_GHA_PAT }}
- inputs: '{ "version": "${{steps.get-version.outputs.assembly-version}}" }'
-
- - name: Login to Docker Hub
- uses: docker/login-action@v1
- with:
- username: ${{ secrets.DOCKER_HUB_USERNAME }}
- password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
-
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v1
-
- - name: Set up Docker Buildx
- id: buildx
- uses: docker/setup-buildx-action@v1
-
- - name: Build and push
- id: docker_build
- uses: docker/build-push-action@v2
- with:
- context: .
- platforms: linux/amd64,linux/arm/v7,linux/arm64
- push: true
- tags: kizaing/kavita:nightly
-
- - name: Image digest
- run: echo ${{ steps.docker_build.outputs.digest }}
-
- - name: Notify Discord
- uses: rjstone/discord-webhook-notify@v1
- with:
- severity: info
- description:
- details: 'https://hub.docker.com/r/kizaing/kavita/tags?page=1&ordering=last_updated'
- text: A new nightly build has been released for docker.
- webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }}
diff --git a/.github/workflows/sonar-scan.yml b/.github/workflows/sonar-scan.yml
index b6dfdf351..97480b165 100644
--- a/.github/workflows/sonar-scan.yml
+++ b/.github/workflows/sonar-scan.yml
@@ -2,14 +2,42 @@ name: .NET Build Test and Sonar Scan
on:
push:
- branches: [ main, develop ]
+ branches: '**'
pull_request:
branches: [ main, develop ]
- types: [opened, synchronize, reopened]
+ types: [synchronize]
jobs:
build:
- name: Build and Scan
+ name: Build .Net
+ runs-on: windows-latest
+ steps:
+ - name: Checkout Repo
+ uses: actions/checkout@v2
+ with:
+ fetch-depth: 0
+
+ - name: Setup .NET Core
+ uses: actions/setup-dotnet@v1
+ with:
+ dotnet-version: 5.0.100
+
+ - name: Install dependencies
+ run: dotnet restore
+
+ - name: Set up JDK 11
+ uses: actions/setup-java@v1
+ with:
+ java-version: 1.11
+
+ - uses: actions/upload-artifact@v2
+ with:
+ name: csproj
+ path: Kavita.Common/Kavita.Common.csproj
+
+ test:
+ name: Install Sonar & Test
+ needs: build
runs-on: windows-latest
steps:
- name: Checkout Repo
@@ -52,7 +80,7 @@ jobs:
New-Item -Path .\.sonar\scanner -ItemType Directory
dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner
- - name: Build and analyze
+ - name: Sonar Scan
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
@@ -64,3 +92,242 @@ jobs:
- name: Test
run: dotnet test --no-restore --verbosity normal
+
+ version:
+ name: Bump version on Develop push
+ needs: [ build, test ]
+ runs-on: ubuntu-latest
+ if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
+ steps:
+ - uses: actions/checkout@v2
+ with:
+ fetch-depth: 0
+
+ - name: Setup .NET Core
+ uses: actions/setup-dotnet@v1
+ with:
+ dotnet-version: 5.0.100
+
+ - name: Install dependencies
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --configuration Release --no-restore
+
+ - name: Bump versions
+ uses: SiqiLu/dotnet-bump-version@master
+ with:
+ version_files: Kavita.Common/Kavita.Common.csproj
+ github_token: ${{ secrets.REPO_GHA_PAT }}
+
+ develop:
+ name: Build Nightly Docker if Develop push
+ needs: [ build, test, version ]
+ runs-on: ubuntu-latest
+ if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
+ steps:
+ - name: Find Current Pull Request
+ uses: jwalton/gh-find-current-pr@v1.0.2
+ id: findPr
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Parse PR body
+ id: parse-body
+ run: |
+ body="${{ steps.findPr.outputs.body }}"
+ body=${body//\'/}
+ body=${body//'%'/'%25'}
+ body=${body//$'\n'/'%0A'}
+ body=${body//$'\r'/'%0D'}
+ echo $body
+ echo "::set-output name=BODY::$body"
+
+ - name: Check Out Repo
+ uses: actions/checkout@v2
+ with:
+ ref: develop
+
+ - name: NodeJS to Compile WebUI
+ uses: actions/setup-node@v2.1.5
+ with:
+ node-version: '14'
+ - run: |
+ cd UI/Web || exit
+ echo 'Installing web dependencies'
+ npm install
+
+ echo 'Building UI'
+ npm run prod
+
+ echo 'Copying back to Kavita wwwroot'
+ rsync -a dist/ ../../API/wwwroot/
+
+ cd ../ || exit
+
+ - name: Get csproj Version
+ uses: naminodarie/get-net-sdk-project-versions-action@v1
+ id: get-version
+ with:
+ proj-path: Kavita.Common/Kavita.Common.csproj
+
+ - name: Echo csproj version
+ run: echo "${{steps.get-version.outputs.assembly-version}}"
+
+ - name: Compile dotnet app
+ uses: actions/setup-dotnet@v1
+ with:
+ dotnet-version: '5.0.x'
+ - run: ./monorepo-build.sh
+
+ - name: Trigger Sentry workflow
+ uses: benc-uk/workflow-dispatch@v1
+ with:
+ workflow: Sentry Map Release
+ token: ${{ secrets.REPO_GHA_PAT }}
+ inputs: '{ "version": "${{steps.get-version.outputs.assembly-version}}" }'
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v1
+ with:
+ username: ${{ secrets.DOCKER_HUB_USERNAME }}
+ password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v1
+
+ - name: Set up Docker Buildx
+ id: buildx
+ uses: docker/setup-buildx-action@v1
+
+ - name: Build and push
+ id: docker_build
+ uses: docker/build-push-action@v2
+ with:
+ context: .
+ platforms: linux/amd64,linux/arm/v7,linux/arm64
+ push: true
+ tags: kizaing/kavita:nightly
+
+ - name: Image digest
+ run: echo ${{ steps.docker_build.outputs.digest }}
+
+ - name: Notify Discord
+ uses: rjstone/discord-webhook-notify@v1
+ with:
+ severity: info
+ description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }}
+ details: '${{ steps.parse-body.outputs.BODY }}'
+ text: A new nightly build has been released for docker.
+ webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }}
+
+ stable:
+ name: Build Stable Docker if Main push
+ needs: [ build, test ]
+ runs-on: ubuntu-latest
+ if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
+ steps:
+
+ - name: Find Current Pull Request
+ uses: jwalton/gh-find-current-pr@v1.0.2
+ id: findPr
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Parse PR body
+ id: parse-body
+ run: |
+ body="${{ steps.findPr.outputs.body }}"
+ body=${body//\'/}
+ body=${body//'%'/'%25'}
+ body=${body//$'\n'/'%0A'}
+ body=${body//$'\r'/'%0D'}
+ echo $body
+ echo "::set-output name=BODY::$body"
+
+ - name: Check Out Repo
+ uses: actions/checkout@v2
+ with:
+ ref: main
+
+ - name: NodeJS to Compile WebUI
+ uses: actions/setup-node@v2.1.5
+ with:
+ node-version: '14'
+ - run: |
+
+ cd UI/Web || exit
+ echo 'Installing web dependencies'
+ npm install
+
+ echo 'Building UI'
+ npm run prod
+
+ echo 'Copying back to Kavita wwwroot'
+ rsync -a dist/ ../../API/wwwroot/
+
+ cd ../ || exit
+
+ - name: Get csproj Version
+ uses: naminodarie/get-net-sdk-project-versions-action@v1
+ id: get-version
+ with:
+ proj-path: Kavita.Common/Kavita.Common.csproj
+
+ - name: Echo csproj version
+ run: echo "${{steps.get-version.outputs.assembly-version}}"
+
+ - name: Parse Version
+ run: |
+ version='${{steps.get-version.outputs.assembly-version}}'
+ newVersion=${version%.*}
+ echo $newVersion
+ echo "::set-output name=VERSION::$newVersion"
+ id: parse-version
+
+ - name: Compile dotnet app
+ uses: actions/setup-dotnet@v1
+ with:
+ dotnet-version: '5.0.x'
+ - run: ./monorepo-build.sh
+
+ - name: Trigger Sentry workflow
+ uses: benc-uk/workflow-dispatch@v1
+ with:
+ workflow: Sentry Map Release
+ token: ${{ secrets.REPO_GHA_PAT }}
+ inputs: '{ "version": "${{steps.get-version.outputs.assembly-version}}" }'
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v1
+ with:
+ username: ${{ secrets.DOCKER_HUB_USERNAME }}
+ password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v1
+
+ - name: Set up Docker Buildx
+ id: buildx
+ uses: docker/setup-buildx-action@v1
+
+ - name: Build and push
+ id: docker_build
+ uses: docker/build-push-action@v2
+ with:
+ context: .
+ platforms: linux/amd64,linux/arm/v7,linux/arm64
+ push: true
+ tags: kizaing/kavita:latest, kizaing/kavita:${{ steps.parse-version.outputs.VERSION }}
+
+ - name: Image digest
+ run: echo ${{ steps.docker_build.outputs.digest }}
+
+ - name: Notify Discord
+ uses: rjstone/discord-webhook-notify@v1
+ with:
+ severity: info
+ description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }}
+ details: '${{ steps.parse-body.outputs.BODY }}'
+ text: A new stable build has been released.
+ webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }}
diff --git a/.github/workflows/stable-docker.yml b/.github/workflows/stable-docker.yml
deleted file mode 100644
index 6dfed1b30..000000000
--- a/.github/workflows/stable-docker.yml
+++ /dev/null
@@ -1,88 +0,0 @@
-name: Build Stable Docker
-
-on:
- push:
- branches:
- - 'main'
-
-jobs:
- docker:
- runs-on: ubuntu-latest
- steps:
-
- - name: Check Out Repo
- uses: actions/checkout@v2
-
- - name: NodeJS to Compile WebUI
- uses: actions/setup-node@v2.1.5
- with:
- node-version: '14'
- - run: |
-
- cd UI/Web || exit
- echo 'Installing web dependencies'
- npm install
-
- echo 'Building UI'
- npm run prod
-
- echo 'Copying back to Kavita wwwroot'
- rsync -a dist/ ../../API/wwwroot/
-
- cd ../ || exit
-
- - name: Get csproj Version
- uses: naminodarie/get-net-sdk-project-versions-action@v1
- id: get-version
- with:
- proj-path: Kavita.Common/Kavita.Common.csproj
-
- - name: Echo csproj version
- run: echo "${{steps.get-version.outputs.assembly-version}}"
-
- - name: Compile dotnet app
- uses: actions/setup-dotnet@v1
- with:
- dotnet-version: '5.0.x'
- - run: ./monorepo-build.sh
-
- - name: Trigger Sentry workflow
- uses: benc-uk/workflow-dispatch@v1
- with:
- workflow: Sentry Map Release
- token: ${{ secrets.REPO_GHA_PAT }}
- inputs: '{ "version": "${{steps.get-version.outputs.assembly-version}}" }'
-
- - name: Login to Docker Hub
- uses: docker/login-action@v1
- with:
- username: ${{ secrets.DOCKER_HUB_USERNAME }}
- password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
-
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v1
-
- - name: Set up Docker Buildx
- id: buildx
- uses: docker/setup-buildx-action@v1
-
- - name: Build and push
- id: docker_build
- uses: docker/build-push-action@v2
- with:
- context: .
- platforms: linux/amd64,linux/arm/v7,linux/arm64
- push: true
- tags: kizaing/kavita:latest
-
- - name: Image digest
- run: echo ${{ steps.docker_build.outputs.digest }}
-
- - name: Notify Discord
- uses: rjstone/discord-webhook-notify@v1
- with:
- severity: info
- description:
- details: 'https://hub.docker.com/r/kizaing/kavita/tags?page=1&ordering=last_updated'
- text: A new stable build has been released for docker.
- webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }}
\ No newline at end of file
diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj
index d486d9877..73a19fd5d 100644
--- a/API.Tests/API.Tests.csproj
+++ b/API.Tests/API.Tests.csproj
@@ -7,15 +7,15 @@
-
-
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
diff --git a/API.Tests/Extensions/FileInfoExtensionsTests.cs b/API.Tests/Extensions/FileInfoExtensionsTests.cs
index 2f385b63f..5e17ecaeb 100644
--- a/API.Tests/Extensions/FileInfoExtensionsTests.cs
+++ b/API.Tests/Extensions/FileInfoExtensionsTests.cs
@@ -1,21 +1,33 @@
-namespace API.Tests.Extensions
+using System;
+using System.Globalization;
+using System.IO;
+using API.Extensions;
+using Xunit;
+
+namespace API.Tests.Extensions
{
public class FileInfoExtensionsTests
{
- // [Fact]
- // public void DoesLastWriteMatchTest()
- // {
- // var fi = Substitute.For();
- // fi.LastWriteTime = DateTime.Now;
- //
- // var deltaTime = DateTime.Today.Subtract(TimeSpan.FromDays(1));
- // Assert.False(fi.DoesLastWriteMatch(deltaTime));
- // }
- //
- // [Fact]
- // public void IsLastWriteLessThanTest()
- // {
- //
- // }
+ private static readonly string TestDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Extensions/Test Data/");
+
+ [Fact]
+ public void HasFileBeenModifiedSince_ShouldBeFalse()
+ {
+ var filepath = Path.Join(TestDirectory, "not modified.txt");
+ var date = new FileInfo(filepath).LastWriteTime;
+ Assert.False(new FileInfo(filepath).HasFileBeenModifiedSince(date));
+ File.ReadAllText(filepath);
+ Assert.False(new FileInfo(filepath).HasFileBeenModifiedSince(date));
+ }
+
+ [Fact]
+ public void HasFileBeenModifiedSince_ShouldBeTrue()
+ {
+ var filepath = Path.Join(TestDirectory, "modified on run.txt");
+ var date = new FileInfo(filepath).LastWriteTime;
+ Assert.False(new FileInfo(filepath).HasFileBeenModifiedSince(date));
+ File.AppendAllLines(filepath, new[] { DateTime.Now.ToString(CultureInfo.InvariantCulture) });
+ Assert.True(new FileInfo(filepath).HasFileBeenModifiedSince(date));
+ }
}
-}
\ No newline at end of file
+}
diff --git a/API.Tests/Extensions/Test Data/modified on run.txt b/API.Tests/Extensions/Test Data/modified on run.txt
new file mode 100644
index 000000000..d6a609edc
--- /dev/null
+++ b/API.Tests/Extensions/Test Data/modified on run.txt
@@ -0,0 +1,3 @@
+This file should be modified by the unit test08/20/2021 10:26:03
+08/20/2021 10:26:29
+08/22/2021 12:39:58
diff --git a/API.Tests/Extensions/Test Data/not modified.txt b/API.Tests/Extensions/Test Data/not modified.txt
new file mode 100644
index 000000000..d5c0ce0a5
--- /dev/null
+++ b/API.Tests/Extensions/Test Data/not modified.txt
@@ -0,0 +1 @@
+Hello, this file should not be modified
\ No newline at end of file
diff --git a/API.Tests/Parser/ComicParserTests.cs b/API.Tests/Parser/ComicParserTests.cs
index 6e33dd89c..a18ea21c9 100644
--- a/API.Tests/Parser/ComicParserTests.cs
+++ b/API.Tests/Parser/ComicParserTests.cs
@@ -22,11 +22,12 @@ namespace API.Tests.Parser
[InlineData("Invincible Vol 01 Family matters (2005) (Digital).cbr", "Invincible")]
[InlineData("Amazing Man Comics chapter 25", "Amazing Man Comics")]
[InlineData("Amazing Man Comics issue #25", "Amazing Man Comics")]
+ [InlineData("Teen Titans v1 038 (1972) (c2c).cbr", "Teen Titans")]
public void ParseComicSeriesTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseComicSeries(filename));
}
-
+
[Theory]
[InlineData("01 Spider-Man & Wolverine 01.cbr", "1")]
[InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", "4")]
@@ -47,7 +48,7 @@ namespace API.Tests.Parser
{
Assert.Equal(expected, API.Parser.Parser.ParseComicVolume(filename));
}
-
+
[Theory]
[InlineData("01 Spider-Man & Wolverine 01.cbr", "0")]
[InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", "0")]
@@ -70,4 +71,4 @@ namespace API.Tests.Parser
Assert.Equal(expected, API.Parser.Parser.ParseComicChapter(filename));
}
}
-}
\ No newline at end of file
+}
diff --git a/API.Tests/Parser/MangaParserTests.cs b/API.Tests/Parser/MangaParserTests.cs
index b03fda33a..348902f84 100644
--- a/API.Tests/Parser/MangaParserTests.cs
+++ b/API.Tests/Parser/MangaParserTests.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using API.Entities.Enums;
using API.Parser;
using Xunit;
@@ -64,6 +64,7 @@ namespace API.Tests.Parser
[InlineData("Sword Art Online Vol 10 - Alicization Running [Yen Press] [LuCaZ] {r2}.epub", "10")]
[InlineData("Noblesse - Episode 406 (52 Pages).7z", "0")]
[InlineData("X-Men v1 #201 (September 2007).cbz", "1")]
+ [InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "6")]
public void ParseVolumeTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseVolume(filename));
@@ -154,6 +155,7 @@ namespace API.Tests.Parser
[InlineData("Please Go Home, Akutsu-San! - Chapter 038.5 - Volume Announcement.cbz", "Please Go Home, Akutsu-San!")]
[InlineData("Killing Bites - Vol 11 Chapter 050 Save Me, Nunupi!.cbz", "Killing Bites")]
[InlineData("Mad Chimera World - Volume 005 - Chapter 026.cbz", "Mad Chimera World")]
+ [InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "Hentai Ouji to Warawanai Neko.")]
public void ParseSeriesTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename));
@@ -222,6 +224,7 @@ namespace API.Tests.Parser
[InlineData("Boku No Kokoro No Yabai Yatsu - Chapter 054 I Prayed At The Shrine (V0).cbz", "54")]
[InlineData("Ijousha No Ai - Vol.01 Chapter 029 8 Years Ago", "29")]
[InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 09.cbz", "9")]
+ [InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "34.5")]
public void ParseChaptersTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseChapter(filename));
@@ -275,6 +278,7 @@ namespace API.Tests.Parser
Assert.Equal(expected, API.Parser.Parser.ParseMangaSpecial(inputFile));
}
+/*
private static ParserInfo CreateParserInfo(string series, string chapter, string volume, bool isSpecial = false)
{
return new ParserInfo()
@@ -285,6 +289,7 @@ namespace API.Tests.Parser
Series = series,
};
}
+*/
[Theory]
[InlineData("/manga/Btooom!/Vol.1/Chapter 1/1.cbz", "Btooom!~1~1")]
@@ -402,6 +407,10 @@ namespace API.Tests.Parser
FullFilePath = filepath, IsSpecial = false
});
+ // If an image is cover exclusively, ignore it
+ filepath = @"E:\Manga\Seraph of the End\cover.png";
+ expected.Add(filepath, null);
+
foreach (var file in expected.Keys)
{
diff --git a/API.Tests/Services/MetadataServiceTests.cs b/API.Tests/Services/MetadataServiceTests.cs
new file mode 100644
index 000000000..3f550f80a
--- /dev/null
+++ b/API.Tests/Services/MetadataServiceTests.cs
@@ -0,0 +1,105 @@
+using System;
+using System.IO;
+using API.Entities;
+using API.Interfaces;
+using API.Interfaces.Services;
+using API.Services;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Xunit;
+
+namespace API.Tests.Services
+{
+ public class MetadataServiceTests
+ {
+ private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives");
+ private readonly MetadataService _metadataService;
+ private readonly IUnitOfWork _unitOfWork = Substitute.For();
+ private readonly IImageService _imageService = Substitute.For();
+ private readonly IBookService _bookService = Substitute.For();
+ private readonly IArchiveService _archiveService = Substitute.For();
+ private readonly ILogger _logger = Substitute.For>();
+
+ public MetadataServiceTests()
+ {
+ _metadataService = new MetadataService(_unitOfWork, _logger, _archiveService, _bookService, _imageService);
+ }
+
+ [Fact]
+ public void ShouldUpdateCoverImage_OnFirstRun()
+ {
+ // Represents first run
+ Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile()
+ {
+ FilePath = Path.Join(_testDirectory, "file in folder.zip"),
+ LastModified = DateTime.Now
+ }, false, false));
+ }
+
+ [Fact]
+ public void ShouldUpdateCoverImage_OnSecondRun_FileModified()
+ {
+ // Represents first run
+ Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile()
+ {
+ FilePath = Path.Join(_testDirectory, "file in folder.zip"),
+ LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime.Subtract(TimeSpan.FromDays(1))
+ }, false, false));
+ }
+
+ [Fact]
+ public void ShouldUpdateCoverImage_OnSecondRun_CoverImageLocked()
+ {
+ // Represents first run
+ Assert.False(MetadataService.ShouldUpdateCoverImage(null, new MangaFile()
+ {
+ FilePath = Path.Join(_testDirectory, "file in folder.zip"),
+ LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime
+ }, false, true));
+ }
+
+ [Fact]
+ public void ShouldUpdateCoverImage_OnSecondRun_ForceUpdate()
+ {
+ // Represents first run
+ Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile()
+ {
+ FilePath = Path.Join(_testDirectory, "file in folder.zip"),
+ LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime
+ }, true, false));
+ }
+
+ [Fact]
+ public void ShouldUpdateCoverImage_OnSecondRun_NoFileChangeButNoCoverImage()
+ {
+ // Represents first run
+ Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile()
+ {
+ FilePath = Path.Join(_testDirectory, "file in folder.zip"),
+ LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime
+ }, false, false));
+ }
+
+ [Fact]
+ public void ShouldUpdateCoverImage_OnSecondRun_FileChangeButNoCoverImage()
+ {
+ // Represents first run
+ Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile()
+ {
+ FilePath = Path.Join(_testDirectory, "file in folder.zip"),
+ LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime + TimeSpan.FromDays(1)
+ }, false, false));
+ }
+
+ [Fact]
+ public void ShouldUpdateCoverImage_OnSecondRun_CoverImageSet()
+ {
+ // Represents first run
+ Assert.False(MetadataService.ShouldUpdateCoverImage(new byte[] {1}, new MangaFile()
+ {
+ FilePath = Path.Join(_testDirectory, "file in folder.zip"),
+ LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime
+ }, false, false));
+ }
+ }
+}
diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs
index c25a7012d..2c7a999f0 100644
--- a/API.Tests/Services/ScannerServiceTests.cs
+++ b/API.Tests/Services/ScannerServiceTests.cs
@@ -33,7 +33,6 @@ namespace API.Tests.Services
private readonly IBookService _bookService = Substitute.For();
private readonly IImageService _imageService = Substitute.For();
private readonly ILogger _metadataLogger = Substitute.For>();
- private readonly IDirectoryService _directoryService = Substitute.For();
private readonly ICacheService _cacheService = Substitute.For();
private readonly DbConnection _connection;
diff --git a/API/API.csproj b/API/API.csproj
index 997ca6817..58709db38 100644
--- a/API/API.csproj
+++ b/API/API.csproj
@@ -12,6 +12,10 @@
../favicon.ico
+
+ bin\Debug\API.xml
+
+
Kavita
@@ -33,33 +37,37 @@
-
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
+
+
+
-
-
+
+
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
-
+
@@ -73,22 +81,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -215,4 +241,8 @@
<_ContentIncludedByDefault Remove="wwwroot\vendor.6b2a0912ae80e6fd297f.js.map" />
+
+
+
+
diff --git a/API/Archive/ArchiveLibrary.cs b/API/Archive/ArchiveLibrary.cs
index 2d05a7a55..2d87e24b6 100644
--- a/API/Archive/ArchiveLibrary.cs
+++ b/API/Archive/ArchiveLibrary.cs
@@ -5,8 +5,17 @@
///
public enum ArchiveLibrary
{
+ ///
+ /// The underlying archive cannot be opened
+ ///
NotSupported = 0,
+ ///
+ /// The underlying archive can be opened by SharpCompress
+ ///
SharpCompress = 1,
+ ///
+ /// The underlying archive can be opened by default .NET
+ ///
Default = 2
}
-}
\ No newline at end of file
+}
diff --git a/API/Archive/CoverAndPages.cs b/API/Archive/CoverAndPages.cs
deleted file mode 100644
index d40d3009c..000000000
--- a/API/Archive/CoverAndPages.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace API.Archive
-{
- public class CoverAndPages
- {
-
- }
-}
\ No newline at end of file
diff --git a/API/Constants/PolicyConstants.cs b/API/Constants/PolicyConstants.cs
index c76d71926..389a87eee 100644
--- a/API/Constants/PolicyConstants.cs
+++ b/API/Constants/PolicyConstants.cs
@@ -1,12 +1,21 @@
namespace API.Constants
{
+ ///
+ /// Role-based Security
+ ///
public static class PolicyConstants
{
+ ///
+ /// Admin User. Has all privileges
+ ///
public const string AdminRole = "Admin";
+ ///
+ /// Non-Admin User. Must be granted privileges by an Admin.
+ ///
public const string PlebRole = "Pleb";
///
/// Used to give a user ability to download files from the server
///
public const string DownloadRole = "Download";
}
-}
\ No newline at end of file
+}
diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs
index 876cecf84..18635ea03 100644
--- a/API/Controllers/AccountController.cs
+++ b/API/Controllers/AccountController.cs
@@ -18,6 +18,9 @@ using Microsoft.Extensions.Logging;
namespace API.Controllers
{
+ ///
+ /// All Account matters
+ ///
public class AccountController : BaseApiController
{
private readonly UserManager _userManager;
@@ -27,9 +30,10 @@ namespace API.Controllers
private readonly ILogger _logger;
private readonly IMapper _mapper;
+ ///
public AccountController(UserManager userManager,
- SignInManager signInManager,
- ITokenService tokenService, IUnitOfWork unitOfWork,
+ SignInManager signInManager,
+ ITokenService tokenService, IUnitOfWork unitOfWork,
ILogger logger,
IMapper mapper)
{
@@ -40,7 +44,12 @@ namespace API.Controllers
_logger = logger;
_mapper = mapper;
}
-
+
+ ///
+ /// Update a user's password
+ ///
+ ///
+ ///
[HttpPost("reset-password")]
public async Task UpdatePassword(ResetPasswordDto resetPasswordDto)
{
@@ -49,7 +58,7 @@ namespace API.Controllers
if (resetPasswordDto.UserName != User.GetUsername() && !User.IsInRole(PolicyConstants.AdminRole))
return Unauthorized("You are not permitted to this operation.");
-
+
// Validate Password
foreach (var validator in _userManager.PasswordValidators)
{
@@ -60,26 +69,31 @@ namespace API.Controllers
validationResult.Errors.Select(e => new ApiException(400, e.Code, e.Description)));
}
}
-
+
var result = await _userManager.RemovePasswordAsync(user);
if (!result.Succeeded)
{
_logger.LogError("Could not update password");
return BadRequest(result.Errors.Select(e => new ApiException(400, e.Code, e.Description)));
}
-
-
+
+
result = await _userManager.AddPasswordAsync(user, resetPasswordDto.Password);
if (!result.Succeeded)
{
_logger.LogError("Could not update password");
return BadRequest(result.Errors.Select(e => new ApiException(400, e.Code, e.Description)));
}
-
+
_logger.LogInformation("{User}'s Password has been reset", resetPasswordDto.UserName);
return Ok();
}
+ ///
+ /// Register a new user on the server
+ ///
+ ///
+ ///
[HttpPost("register")]
public async Task> Register(RegisterDto registerDto)
{
@@ -134,6 +148,11 @@ namespace API.Controllers
return BadRequest("Something went wrong when registering user");
}
+ ///
+ /// Perform a login. Will send JWT Token of the logged in user back.
+ ///
+ ///
+ ///
[HttpPost("login")]
public async Task> Login(LoginDto loginDto)
{
@@ -147,14 +166,14 @@ namespace API.Controllers
.CheckPasswordSignInAsync(user, loginDto.Password, false);
if (!result.Succeeded) return Unauthorized("Your credentials are not correct.");
-
+
// Update LastActive on account
user.LastActive = DateTime.Now;
user.UserPreferences ??= new AppUserPreferences();
-
+
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
-
+
_logger.LogInformation("{UserName} logged in at {Time}", user.UserName, user.LastActive);
return new UserDto
@@ -165,6 +184,10 @@ namespace API.Controllers
};
}
+ ///
+ /// Get All Roles back. See
+ ///
+ ///
[HttpGet("roles")]
public ActionResult> GetRoles()
{
@@ -175,6 +198,11 @@ namespace API.Controllers
f => (string) f.GetValue(null)).Values.ToList();
}
+ ///
+ /// Sets the given roles to the user.
+ ///
+ ///
+ ///
[HttpPost("update-rbs")]
public async Task UpdateRoles(UpdateRbsDto updateRbsDto)
{
@@ -190,7 +218,7 @@ namespace API.Controllers
var existingRoles = (await _userManager.GetRolesAsync(user))
.Where(s => s != PolicyConstants.AdminRole && s != PolicyConstants.PlebRole)
.ToList();
-
+
// Find what needs to be added and what needs to be removed
var rolesToRemove = existingRoles.Except(updateRbsDto.Roles);
var result = await _userManager.AddToRolesAsync(user, updateRbsDto.Roles);
@@ -204,10 +232,10 @@ namespace API.Controllers
{
return Ok();
}
-
+
await _unitOfWork.RollbackAsync();
return BadRequest("Something went wrong, unable to update user's roles");
}
}
-}
\ No newline at end of file
+}
diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs
index 0efa6c71e..d48b75707 100644
--- a/API/Controllers/BookController.cs
+++ b/API/Controllers/BookController.cs
@@ -18,14 +18,16 @@ namespace API.Controllers
private readonly ILogger _logger;
private readonly IBookService _bookService;
private readonly IUnitOfWork _unitOfWork;
+ private readonly ICacheService _cacheService;
private static readonly string BookApiUrl = "book-resources?file=";
- public BookController(ILogger logger, IBookService bookService, IUnitOfWork unitOfWork)
+ public BookController(ILogger logger, IBookService bookService, IUnitOfWork unitOfWork, ICacheService cacheService)
{
_logger = logger;
_bookService = bookService;
_unitOfWork = unitOfWork;
+ _cacheService = cacheService;
}
[HttpGet("{chapterId}/book-info")]
@@ -169,9 +171,11 @@ namespace API.Controllers
[HttpGet("{chapterId}/book-page")]
public async Task> GetBookPage(int chapterId, [FromQuery] int page)
{
- var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId);
+ var chapter = await _cacheService.Ensure(chapterId);
+ var path = _cacheService.GetCachedEpubFile(chapter.Id, chapter);
- using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath);
+
+ using var book = await EpubReader.OpenBookAsync(path);
var mappings = await _bookService.CreateKeyToPageMappingAsync(book);
var counter = 0;
@@ -196,12 +200,7 @@ namespace API.Controllers
{
if (doc.ParseErrors.Any())
{
- _logger.LogError("{FilePath} has an invalid html file (Page {PageName})", book.FilePath, contentFileRef.FileName);
- foreach (var error in doc.ParseErrors)
- {
- _logger.LogError("Line {LineNumber}, Reason: {Reason}", error.Line, error.Reason);
- }
-
+ LogBookErrors(book, contentFileRef, doc);
return BadRequest("The file is malformed! Cannot read.");
}
_logger.LogError("{FilePath} has no body tag! Generating one for support. Book may be skewed", book.FilePath);
@@ -322,5 +321,14 @@ namespace API.Controllers
return BadRequest("Could not find the appropriate html for that page");
}
+
+ private void LogBookErrors(EpubBookRef book, EpubTextContentFileRef contentFileRef, HtmlDocument doc)
+ {
+ _logger.LogError("{FilePath} has an invalid html file (Page {PageName})", book.FilePath, contentFileRef.FileName);
+ foreach (var error in doc.ParseErrors)
+ {
+ _logger.LogError("Line {LineNumber}, Reason: {Reason}", error.Line, error.Reason);
+ }
+ }
}
}
diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs
index e09f8592a..8bf10c09a 100644
--- a/API/Controllers/CollectionController.cs
+++ b/API/Controllers/CollectionController.cs
@@ -13,17 +13,25 @@ using Microsoft.AspNetCore.Mvc;
namespace API.Controllers
{
+ ///
+ /// APIs for Collections
+ ///
public class CollectionController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly UserManager _userManager;
+ ///
public CollectionController(IUnitOfWork unitOfWork, UserManager userManager)
{
_unitOfWork = unitOfWork;
_userManager = userManager;
}
+ ///
+ /// Return a list of all collection tags on the server
+ ///
+ ///
[HttpGet]
public async Task> GetAllTags()
{
@@ -31,11 +39,17 @@ namespace API.Controllers
var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
if (isAdmin)
{
- return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
+ return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
}
return await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync();
}
-
+
+ ///
+ /// 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 %
+ ///
+ /// Search term
+ ///
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("search")]
public async Task> SearchTags(string queryString)
@@ -43,20 +57,27 @@ namespace API.Controllers
queryString ??= "";
queryString = queryString.Replace(@"%", "");
if (queryString.Length == 0) return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
-
+
return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString);
}
-
+
+ ///
+ /// Updates an existing tag with a new title, promotion status, and summary.
+ /// UI does not contain controls to update title
+ ///
+ ///
+ ///
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update")]
- public async Task UpdateTag(CollectionTagDto updatedTag)
+ public async Task UpdateTagPromotion(CollectionTagDto updatedTag)
{
var existingTag = await _unitOfWork.CollectionTagRepository.GetTagAsync(updatedTag.Id);
if (existingTag == null) return BadRequest("This tag does not exist");
existingTag.Promoted = updatedTag.Promoted;
- existingTag.Title = updatedTag.Title;
+ existingTag.Title = updatedTag.Title.Trim();
existingTag.NormalizedTitle = Parser.Parser.Normalize(updatedTag.Title).ToUpper();
+ existingTag.Summary = updatedTag.Summary.Trim();
if (_unitOfWork.HasChanges())
{
@@ -73,6 +94,11 @@ namespace API.Controllers
return BadRequest("Something went wrong, please try again");
}
+ ///
+ /// For a given tag, update the summary if summary has changed and remove a set of series from the tag.
+ ///
+ ///
+ ///
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update-series")]
public async Task UpdateSeriesForTag(UpdateSeriesForTagDto updateSeriesForTagDto)
@@ -90,6 +116,15 @@ namespace API.Controllers
_unitOfWork.CollectionTagRepository.Update(tag);
}
+ tag.CoverImageLocked = updateSeriesForTagDto.Tag.CoverImageLocked;
+
+ if (!updateSeriesForTagDto.Tag.CoverImageLocked)
+ {
+ tag.CoverImageLocked = false;
+ tag.CoverImage = Array.Empty();
+ _unitOfWork.CollectionTagRepository.Update(tag);
+ }
+
foreach (var seriesIdToRemove in updateSeriesForTagDto.SeriesIdsToRemove)
{
tag.SeriesMetadatas.Remove(tag.SeriesMetadatas.Single(sm => sm.SeriesId == seriesIdToRemove));
@@ -101,7 +136,9 @@ namespace API.Controllers
_unitOfWork.CollectionTagRepository.Remove(tag);
}
- if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync())
+ if (!_unitOfWork.HasChanges()) return Ok("No updates");
+
+ if (await _unitOfWork.CommitAsync())
{
return Ok("Tag updated");
}
@@ -110,9 +147,9 @@ namespace API.Controllers
{
await _unitOfWork.RollbackAsync();
}
-
-
+
+
return BadRequest("Something went wrong. Please try again.");
}
}
-}
\ No newline at end of file
+}
diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs
index 3e89c98ef..21e2a77cb 100644
--- a/API/Controllers/DownloadController.cs
+++ b/API/Controllers/DownloadController.cs
@@ -3,7 +3,10 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
+using API.Comparators;
+using API.DTOs.Downloads;
using API.Entities;
+using API.Entities.Enums;
using API.Extensions;
using API.Interfaces;
using API.Interfaces.Services;
@@ -21,12 +24,17 @@ namespace API.Controllers
private readonly IUnitOfWork _unitOfWork;
private readonly IArchiveService _archiveService;
private readonly IDirectoryService _directoryService;
+ private readonly ICacheService _cacheService;
+ private readonly NumericComparer _numericComparer;
+ private const string DefaultContentType = "application/octet-stream"; // "application/zip"
- public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService)
+ public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService, ICacheService cacheService)
{
_unitOfWork = unitOfWork;
_archiveService = archiveService;
_directoryService = directoryService;
+ _cacheService = cacheService;
+ _numericComparer = new NumericComparer();
}
[HttpGet("volume-size")]
@@ -39,7 +47,7 @@ namespace API.Controllers
[HttpGet("chapter-size")]
public async Task> GetChapterSize(int chapterId)
{
- var files = await _unitOfWork.VolumeRepository.GetFilesForChapter(chapterId);
+ var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId);
return Ok(DirectoryService.GetTotalSize(files.Select(c => c.FilePath)));
}
@@ -54,15 +62,17 @@ namespace API.Controllers
public async Task DownloadVolume(int volumeId)
{
var files = await _unitOfWork.VolumeRepository.GetFilesForVolume(volumeId);
+ var volume = await _unitOfWork.SeriesRepository.GetVolumeByIdAsync(volumeId);
+ var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId);
try
{
if (files.Count == 1)
{
return await GetFirstFileDownload(files);
}
- var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
+ var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
$"download_{User.GetUsername()}_v{volumeId}");
- return File(fileBytes, "application/zip", Path.GetFileNameWithoutExtension(zipPath) + ".zip");
+ return File(fileBytes, DefaultContentType, $"{series.Name} - Volume {volume.Number}.zip");
}
catch (KavitaException ex)
{
@@ -96,16 +106,19 @@ namespace API.Controllers
[HttpGet("chapter")]
public async Task DownloadChapter(int chapterId)
{
- var files = await _unitOfWork.VolumeRepository.GetFilesForChapter(chapterId);
+ var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId);
+ var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId);
+ var volume = await _unitOfWork.SeriesRepository.GetVolumeByIdAsync(chapter.VolumeId);
+ var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId);
try
{
if (files.Count == 1)
{
return await GetFirstFileDownload(files);
}
- var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
+ var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
$"download_{User.GetUsername()}_c{chapterId}");
- return File(fileBytes, "application/zip", Path.GetFileNameWithoutExtension(zipPath) + ".zip");
+ return File(fileBytes, DefaultContentType, $"{series.Name} - Chapter {chapter.Number}.zip");
}
catch (KavitaException ex)
{
@@ -117,20 +130,78 @@ namespace API.Controllers
public async Task DownloadSeries(int seriesId)
{
var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId);
+ var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
try
{
if (files.Count == 1)
{
return await GetFirstFileDownload(files);
}
- var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
+ var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
$"download_{User.GetUsername()}_s{seriesId}");
- return File(fileBytes, "application/zip", Path.GetFileNameWithoutExtension(zipPath) + ".zip");
+ return File(fileBytes, DefaultContentType, $"{series.Name}.zip");
}
catch (KavitaException ex)
{
return BadRequest(ex.Message);
}
}
+
+ [HttpPost("bookmarks")]
+ public async Task DownloadBookmarkPages(DownloadBookmarkDto downloadBookmarkDto)
+ {
+ // We know that all bookmarks will be for one single seriesId
+ var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(downloadBookmarkDto.Bookmarks.First().SeriesId);
+ var totalFilePaths = new List();
+
+ var tempFolder = $"download_{series.Id}_bookmarks";
+ var fullExtractPath = Path.Join(DirectoryService.TempDirectory, tempFolder);
+ if (new DirectoryInfo(fullExtractPath).Exists)
+ {
+ return BadRequest(
+ "Server is currently processing this exact download. Please try again in a few minutes.");
+ }
+ DirectoryService.ExistOrCreate(fullExtractPath);
+
+ var uniqueChapterIds = downloadBookmarkDto.Bookmarks.Select(b => b.ChapterId).Distinct().ToList();
+
+ foreach (var chapterId in uniqueChapterIds)
+ {
+ var chapterExtractPath = Path.Join(fullExtractPath, $"{series.Id}_bookmark_{chapterId}");
+ var chapterPages = downloadBookmarkDto.Bookmarks.Where(b => b.ChapterId == chapterId)
+ .Select(b => b.Page).ToList();
+ var mangaFiles = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId);
+ switch (series.Format)
+ {
+ case MangaFormat.Image:
+ DirectoryService.ExistOrCreate(chapterExtractPath);
+ _directoryService.CopyFilesToDirectory(mangaFiles.Select(f => f.FilePath), chapterExtractPath, $"{chapterId}_");
+ break;
+ case MangaFormat.Archive:
+ case MangaFormat.Pdf:
+ _cacheService.ExtractChapterFiles(chapterExtractPath, mangaFiles.ToList());
+ var originalFiles = _directoryService.GetFilesWithExtension(chapterExtractPath,
+ Parser.Parser.ImageFileExtensions);
+ _directoryService.CopyFilesToDirectory(originalFiles, chapterExtractPath, $"{chapterId}_");
+ DirectoryService.DeleteFiles(originalFiles);
+ break;
+ case MangaFormat.Epub:
+ return BadRequest("Series is not in a valid format.");
+ default:
+ return BadRequest("Series is not in a valid format. Please rescan series and try again.");
+ }
+
+ var files = _directoryService.GetFilesWithExtension(chapterExtractPath, Parser.Parser.ImageFileExtensions);
+ // Filter out images that aren't in bookmarks
+ Array.Sort(files, _numericComparer);
+ totalFilePaths.AddRange(files.Where((_, i) => chapterPages.Contains(i)));
+ }
+
+
+ var (fileBytes, _) = await _archiveService.CreateZipForDownload(totalFilePaths,
+ tempFolder);
+ DirectoryService.ClearAndDeleteDirectory(fullExtractPath);
+ return File(fileBytes, DefaultContentType, $"{series.Name} - Bookmarks.zip");
+ }
}
}
diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs
index 234ef2ae6..31da9c54b 100644
--- a/API/Controllers/ImageController.cs
+++ b/API/Controllers/ImageController.cs
@@ -5,57 +5,78 @@ using Microsoft.AspNetCore.Mvc;
namespace API.Controllers
{
+ ///
+ /// Responsible for servicing up images stored in the DB
+ ///
public class ImageController : BaseApiController
{
+ private const string Format = "jpeg";
private readonly IUnitOfWork _unitOfWork;
+ ///
public ImageController(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
-
+
+ ///
+ /// Returns cover image for Chapter
+ ///
+ ///
+ ///
[HttpGet("chapter-cover")]
public async Task GetChapterCoverImage(int chapterId)
{
var content = await _unitOfWork.VolumeRepository.GetChapterCoverImageAsync(chapterId);
if (content == null) return BadRequest("No cover image");
- const string format = "jpeg";
Response.AddCacheHeader(content);
- return File(content, "image/" + format, $"chapterId");
+ return File(content, "image/" + Format, $"{chapterId}");
}
+ ///
+ /// Returns cover image for Volume
+ ///
+ ///
+ ///
[HttpGet("volume-cover")]
public async Task GetVolumeCoverImage(int volumeId)
{
var content = await _unitOfWork.SeriesRepository.GetVolumeCoverImageAsync(volumeId);
if (content == null) return BadRequest("No cover image");
- const string format = "jpeg";
Response.AddCacheHeader(content);
- return File(content, "image/" + format, $"volumeId");
+ return File(content, "image/" + Format, $"{volumeId}");
}
-
+
+ ///
+ /// Returns cover image for Series
+ ///
+ /// Id of Series
+ ///
[HttpGet("series-cover")]
public async Task GetSeriesCoverImage(int seriesId)
{
var content = await _unitOfWork.SeriesRepository.GetSeriesCoverImageAsync(seriesId);
if (content == null) return BadRequest("No cover image");
- const string format = "jpeg";
Response.AddCacheHeader(content);
- return File(content, "image/" + format, $"seriesId");
+ return File(content, "image/" + Format, $"{seriesId}");
}
-
+
+ ///
+ /// Returns cover image for Collection Tag
+ ///
+ ///
+ ///
[HttpGet("collection-cover")]
public async Task GetCollectionCoverImage(int collectionTagId)
{
var content = await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId);
if (content == null) return BadRequest("No cover image");
- const string format = "jpeg";
Response.AddCacheHeader(content);
- return File(content, "image/" + format, $"collectionTagId");
+ return File(content, "image/" + Format, $"{collectionTagId}");
}
}
-}
\ No newline at end of file
+}
diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs
index 4d47e3f06..53c3953ac 100644
--- a/API/Controllers/LibraryController.cs
+++ b/API/Controllers/LibraryController.cs
@@ -185,6 +185,8 @@ namespace API.Controllers
if (chapterIds.Any())
{
+ await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
+ await _unitOfWork.CommitAsync();
_taskScheduler.CleanupChapters(chapterIds);
}
return Ok(true);
@@ -203,8 +205,7 @@ namespace API.Controllers
{
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryForUserDto.Id);
- var originalFolders = library.Folders.Select(x => x.Path);
- var differenceBetweenFolders = originalFolders.Except(libraryForUserDto.Folders);
+ var originalFolders = library.Folders.Select(x => x.Path).ToList();
library.Name = libraryForUserDto.Name;
library.Folders = libraryForUserDto.Folders.Select(s => new FolderPath() {Path = s}).ToList();
@@ -212,9 +213,9 @@ namespace API.Controllers
_unitOfWork.LibraryRepository.Update(library);
if (!await _unitOfWork.CommitAsync()) return BadRequest("There was a critical issue updating the library.");
- if (differenceBetweenFolders.Any())
+ if (originalFolders.Count != libraryForUserDto.Folders.Count())
{
- _taskScheduler.ScanLibrary(library.Id, true);
+ _taskScheduler.ScanLibrary(library.Id);
}
return Ok();
diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs
index 746d3a6ce..6e37ae38a 100644
--- a/API/Controllers/ReaderController.cs
+++ b/API/Controllers/ReaderController.cs
@@ -11,25 +11,38 @@ using API.Extensions;
using API.Interfaces;
using API.Interfaces.Services;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
namespace API.Controllers
{
+ ///
+ /// For all things regarding reading, mainly focusing on non-Book related entities
+ ///
public class ReaderController : BaseApiController
{
private readonly IDirectoryService _directoryService;
private readonly ICacheService _cacheService;
private readonly IUnitOfWork _unitOfWork;
+ private readonly ILogger _logger;
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
private readonly NaturalSortComparer _naturalSortComparer = new NaturalSortComparer();
- public ReaderController(IDirectoryService directoryService, ICacheService cacheService, IUnitOfWork unitOfWork)
+ ///
+ public ReaderController(IDirectoryService directoryService, ICacheService cacheService, IUnitOfWork unitOfWork, ILogger logger)
{
_directoryService = directoryService;
_cacheService = cacheService;
_unitOfWork = unitOfWork;
+ _logger = logger;
}
+ ///
+ /// Returns an image for a given chapter. Side effect: This will cache the chapter images for reading.
+ ///
+ ///
+ ///
+ ///
[HttpGet("image")]
public async Task GetImage(int chapterId, int page)
{
@@ -37,28 +50,43 @@ namespace API.Controllers
var chapter = await _cacheService.Ensure(chapterId);
if (chapter == null) return BadRequest("There was an issue finding image file for reading");
- var (path, _) = await _cacheService.GetCachedPagePath(chapter, page);
- if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}");
+ try
+ {
+ var (path, _) = await _cacheService.GetCachedPagePath(chapter, page);
+ if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}");
- var content = await _directoryService.ReadFileAsync(path);
- var format = Path.GetExtension(path).Replace(".", "");
+ var content = await _directoryService.ReadFileAsync(path);
+ var format = Path.GetExtension(path).Replace(".", "");
- // Calculates SHA1 Hash for byte[]
- Response.AddCacheHeader(content);
+ // Calculates SHA1 Hash for byte[]
+ Response.AddCacheHeader(content);
- return File(content, "image/" + format);
+ return File(content, "image/" + format);
+ }
+ catch (Exception)
+ {
+ _cacheService.CleanupChapters(new []{ chapterId });
+ throw;
+ }
}
+ ///
+ /// Returns various information about a Chapter. Side effect: This will cache the chapter images for reading.
+ ///
+ ///
+ ///
+ ///
[HttpGet("chapter-info")]
- public async Task> GetChapterInfo(int chapterId)
+ public async Task> GetChapterInfo(int seriesId, int chapterId)
{
// PERF: Write this in one DB call
var chapter = await _cacheService.Ensure(chapterId);
if (chapter == null) return BadRequest("Could not find Chapter");
- var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(chapter.VolumeId);
+
+ var volume = await _unitOfWork.SeriesRepository.GetVolumeDtoAsync(chapter.VolumeId);
if (volume == null) return BadRequest("Could not find Volume");
- var (_, mangaFile) = await _cacheService.GetCachedPagePath(chapter, 0);
- var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId);
+ var mangaFile = (await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId)).First();
+ var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
return Ok(new ChapterInfoDto()
{
@@ -72,29 +100,6 @@ namespace API.Controllers
});
}
- [HttpGet("get-bookmark")]
- public async Task> GetBookmark(int chapterId)
- {
- var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
- var bookmark = new BookmarkDto()
- {
- PageNum = 0,
- ChapterId = chapterId,
- VolumeId = 0,
- SeriesId = 0
- };
- if (user.Progresses == null) return Ok(bookmark);
- var progress = user.Progresses.SingleOrDefault(x => x.AppUserId == user.Id && x.ChapterId == chapterId);
-
- if (progress != null)
- {
- bookmark.SeriesId = progress.SeriesId;
- bookmark.VolumeId = progress.VolumeId;
- bookmark.PageNum = progress.PagesRead;
- bookmark.BookScrollId = progress.BookScrollId;
- }
- return Ok(bookmark);
- }
[HttpPost("mark-read")]
public async Task MarkRead(MarkReadDto markReadDto)
@@ -106,8 +111,9 @@ namespace API.Controllers
{
foreach (var chapter in volume.Chapters)
{
- var userProgress = user.Progresses.SingleOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id);
- if (userProgress == null) // I need to get all chapters and generate new user progresses for them?
+ var userProgress = GetUserProgressForChapter(user, chapter);
+
+ if (userProgress == null)
{
user.Progresses.Add(new AppUserProgress
{
@@ -137,6 +143,36 @@ namespace API.Controllers
return BadRequest("There was an issue saving progress");
}
+ private static AppUserProgress GetUserProgressForChapter(AppUser user, Chapter chapter)
+ {
+ AppUserProgress userProgress = null;
+ try
+ {
+ userProgress =
+ user.Progresses.SingleOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id);
+ }
+ catch (Exception)
+ {
+ // There is a very rare chance that user progress will duplicate current row. If that happens delete one with less pages
+ var progresses = user.Progresses.Where(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id).ToList();
+ if (progresses.Count > 1)
+ {
+ user.Progresses = new List()
+ {
+ user.Progresses.First()
+ };
+ userProgress = user.Progresses.First();
+ }
+ }
+
+ return userProgress;
+ }
+
+ ///
+ /// Marks a Chapter as Unread (progress)
+ ///
+ ///
+ ///
[HttpPost("mark-unread")]
public async Task MarkUnread(MarkReadDto markReadDto)
{
@@ -147,23 +183,12 @@ namespace API.Controllers
{
foreach (var chapter in volume.Chapters)
{
- var userProgress = user.Progresses.SingleOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id);
- if (userProgress == null)
- {
- user.Progresses.Add(new AppUserProgress
- {
- PagesRead = 0,
- VolumeId = volume.Id,
- SeriesId = markReadDto.SeriesId,
- ChapterId = chapter.Id
- });
- }
- else
- {
- userProgress.PagesRead = 0;
- userProgress.SeriesId = markReadDto.SeriesId;
- userProgress.VolumeId = volume.Id;
- }
+ var userProgress = GetUserProgressForChapter(user, chapter);
+
+ if (userProgress == null) continue;
+ userProgress.PagesRead = 0;
+ userProgress.SeriesId = markReadDto.SeriesId;
+ userProgress.VolumeId = volume.Id;
}
}
@@ -178,6 +203,11 @@ namespace API.Controllers
return BadRequest("There was an issue saving progress");
}
+ ///
+ /// Marks all chapters within a volume as Read
+ ///
+ ///
+ ///
[HttpPost("mark-volume-read")]
public async Task MarkVolumeAsRead(MarkVolumeReadDto markVolumeReadDto)
{
@@ -187,7 +217,7 @@ namespace API.Controllers
foreach (var chapter in chapters)
{
user.Progresses ??= new List();
- var userProgress = user.Progresses.SingleOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id);
+ var userProgress = user.Progresses.FirstOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id);
if (userProgress == null)
{
@@ -217,48 +247,81 @@ namespace API.Controllers
return BadRequest("Could not save progress");
}
- [HttpPost("bookmark")]
- public async Task Bookmark(BookmarkDto bookmarkDto)
+ ///
+ /// Returns Progress (page number) for a chapter for the logged in user
+ ///
+ ///
+ ///
+ [HttpGet("get-progress")]
+ public async Task> GetProgress(int chapterId)
+ {
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
+ var progressBookmark = new ProgressDto()
+ {
+ PageNum = 0,
+ ChapterId = chapterId,
+ VolumeId = 0,
+ SeriesId = 0
+ };
+ if (user.Progresses == null) return Ok(progressBookmark);
+ var progress = user.Progresses.SingleOrDefault(x => x.AppUserId == user.Id && x.ChapterId == chapterId);
+
+ if (progress != null)
+ {
+ progressBookmark.SeriesId = progress.SeriesId;
+ progressBookmark.VolumeId = progress.VolumeId;
+ progressBookmark.PageNum = progress.PagesRead;
+ progressBookmark.BookScrollId = progress.BookScrollId;
+ }
+ return Ok(progressBookmark);
+ }
+
+ ///
+ /// Save page against Chapter for logged in user
+ ///
+ ///
+ ///
+ [HttpPost("progress")]
+ public async Task BookmarkProgress(ProgressDto progressDto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
- // Don't let user bookmark past total pages.
- var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(bookmarkDto.ChapterId);
- if (bookmarkDto.PageNum > chapter.Pages)
+ // Don't let user save past total pages.
+ var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(progressDto.ChapterId);
+ if (progressDto.PageNum > chapter.Pages)
{
- return BadRequest("Can't bookmark past max pages");
+ progressDto.PageNum = chapter.Pages;
}
- if (bookmarkDto.PageNum < 0)
+ if (progressDto.PageNum < 0)
{
- return BadRequest("Can't bookmark less than 0");
+ progressDto.PageNum = 0;
}
-
try
{
- user.Progresses ??= new List();
+ user.Progresses ??= new List();
var userProgress =
- user.Progresses.SingleOrDefault(x => x.ChapterId == bookmarkDto.ChapterId && x.AppUserId == user.Id);
+ user.Progresses.FirstOrDefault(x => x.ChapterId == progressDto.ChapterId && x.AppUserId == user.Id);
if (userProgress == null)
{
user.Progresses.Add(new AppUserProgress
{
- PagesRead = bookmarkDto.PageNum,
- VolumeId = bookmarkDto.VolumeId,
- SeriesId = bookmarkDto.SeriesId,
- ChapterId = bookmarkDto.ChapterId,
- BookScrollId = bookmarkDto.BookScrollId,
+ PagesRead = progressDto.PageNum,
+ VolumeId = progressDto.VolumeId,
+ SeriesId = progressDto.SeriesId,
+ ChapterId = progressDto.ChapterId,
+ BookScrollId = progressDto.BookScrollId,
LastModified = DateTime.Now
});
}
else
{
- userProgress.PagesRead = bookmarkDto.PageNum;
- userProgress.SeriesId = bookmarkDto.SeriesId;
- userProgress.VolumeId = bookmarkDto.VolumeId;
- userProgress.BookScrollId = bookmarkDto.BookScrollId;
+ userProgress.PagesRead = progressDto.PageNum;
+ userProgress.SeriesId = progressDto.SeriesId;
+ userProgress.VolumeId = progressDto.VolumeId;
+ userProgress.BookScrollId = progressDto.BookScrollId;
userProgress.LastModified = DateTime.Now;
}
@@ -277,6 +340,182 @@ namespace API.Controllers
return BadRequest("Could not save progress");
}
+ ///
+ /// Returns a list of bookmarked pages for a given Chapter
+ ///
+ ///
+ ///
+ [HttpGet("get-bookmarks")]
+ public async Task>> GetBookmarks(int chapterId)
+ {
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
+ if (user.Bookmarks == null) return Ok(Array.Empty());
+ return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForChapter(user.Id, chapterId));
+ }
+
+ ///
+ /// Returns a list of all bookmarked pages for a User
+ ///
+ ///
+ [HttpGet("get-all-bookmarks")]
+ public async Task>> GetAllBookmarks()
+ {
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
+ if (user.Bookmarks == null) return Ok(Array.Empty());
+ return Ok(await _unitOfWork.UserRepository.GetAllBookmarkDtos(user.Id));
+ }
+
+ ///
+ /// Removes all bookmarks for all chapters linked to a Series
+ ///
+ ///
+ ///
+ [HttpPost("remove-bookmarks")]
+ public async Task RemoveBookmarks(RemoveBookmarkForSeriesDto dto)
+ {
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
+ if (user.Bookmarks == null) return Ok("Nothing to remove");
+ try
+ {
+ user.Bookmarks = user.Bookmarks.Where(bmk => bmk.SeriesId != dto.SeriesId).ToList();
+ _unitOfWork.UserRepository.Update(user);
+
+ if (await _unitOfWork.CommitAsync())
+ {
+ return Ok();
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "There was an exception when trying to clear bookmarks");
+ await _unitOfWork.RollbackAsync();
+ }
+
+ return BadRequest("Could not clear bookmarks");
+
+ }
+
+ ///
+ /// Returns all bookmarked pages for a given volume
+ ///
+ ///
+ ///
+ [HttpGet("get-volume-bookmarks")]
+ public async Task>> GetBookmarksForVolume(int volumeId)
+ {
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
+ if (user.Bookmarks == null) return Ok(Array.Empty());
+ return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForVolume(user.Id, volumeId));
+ }
+
+ ///
+ /// Returns all bookmarked pages for a given series
+ ///
+ ///
+ ///
+ [HttpGet("get-series-bookmarks")]
+ public async Task>> GetBookmarksForSeries(int seriesId)
+ {
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
+ if (user.Bookmarks == null) return Ok(Array.Empty());
+
+ return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForSeries(user.Id, seriesId));
+ }
+
+ ///
+ /// Bookmarks a page against a Chapter
+ ///
+ ///
+ ///
+ [HttpPost("bookmark")]
+ public async Task BookmarkPage(BookmarkDto bookmarkDto)
+ {
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
+
+ // Don't let user save past total pages.
+ var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(bookmarkDto.ChapterId);
+ if (bookmarkDto.Page > chapter.Pages)
+ {
+ bookmarkDto.Page = chapter.Pages;
+ }
+
+ if (bookmarkDto.Page < 0)
+ {
+ bookmarkDto.Page = 0;
+ }
+
+
+ try
+ {
+ user.Bookmarks ??= new List();
+ var userBookmark =
+ user.Bookmarks.SingleOrDefault(x => x.ChapterId == bookmarkDto.ChapterId && x.AppUserId == user.Id && x.Page == bookmarkDto.Page);
+
+ if (userBookmark == null)
+ {
+ user.Bookmarks.Add(new AppUserBookmark()
+ {
+ Page = bookmarkDto.Page,
+ VolumeId = bookmarkDto.VolumeId,
+ SeriesId = bookmarkDto.SeriesId,
+ ChapterId = bookmarkDto.ChapterId,
+ });
+ }
+ else
+ {
+ userBookmark.Page = bookmarkDto.Page;
+ userBookmark.SeriesId = bookmarkDto.SeriesId;
+ userBookmark.VolumeId = bookmarkDto.VolumeId;
+ }
+
+ _unitOfWork.UserRepository.Update(user);
+
+ if (await _unitOfWork.CommitAsync())
+ {
+ return Ok();
+ }
+ }
+ catch (Exception)
+ {
+ await _unitOfWork.RollbackAsync();
+ }
+
+ return BadRequest("Could not save bookmark");
+ }
+
+ ///
+ /// Removes a bookmarked page for a Chapter
+ ///
+ ///
+ ///
+ [HttpPost("unbookmark")]
+ public async Task UnBookmarkPage(BookmarkDto bookmarkDto)
+ {
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
+
+ if (user.Bookmarks == null) return Ok();
+ try {
+ user.Bookmarks = user.Bookmarks.Where(x =>
+ x.ChapterId == bookmarkDto.ChapterId
+ && x.AppUserId == user.Id
+ && x.Page != bookmarkDto.Page).ToList();
+
+
+ _unitOfWork.UserRepository.Update(user);
+
+ if (await _unitOfWork.CommitAsync())
+ {
+ return Ok();
+ }
+ }
+ catch (Exception)
+ {
+ await _unitOfWork.RollbackAsync();
+ }
+
+ return BadRequest("Could not remove bookmark");
+ }
+
///
/// Returns the next logical chapter from the series.
///
diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs
index a0dba1e6c..cce70de6d 100644
--- a/API/Controllers/SeriesController.cs
+++ b/API/Controllers/SeriesController.cs
@@ -4,10 +4,12 @@ using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.DTOs;
+using API.DTOs.Filtering;
using API.Entities;
using API.Extensions;
using API.Helpers;
using API.Interfaces;
+using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@@ -27,12 +29,12 @@ namespace API.Controllers
_unitOfWork = unitOfWork;
}
- [HttpGet]
- public async Task>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams)
+ [HttpPost]
+ public async Task>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams, [FromBody] FilterDto filterDto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var series =
- await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, user.Id, userParams);
+ await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, user.Id, userParams, filterDto);
// Apply progress/rating information (I can't work out how to do this in initial query)
if (series == null) return BadRequest("Could not get series for library");
@@ -44,11 +46,26 @@ namespace API.Controllers
return Ok(series);
}
+ ///
+ /// Fetches a Series for a given Id
+ ///
+ /// Series Id to fetch details for
+ ///
+ /// Throws an exception if the series Id does exist
[HttpGet("{seriesId}")]
public async Task> GetSeries(int seriesId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
- return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id));
+ try
+ {
+ return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id));
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "There was an issue fetching {SeriesId}", seriesId);
+ throw new KavitaException("This series does not exist");
+ }
+
}
[Authorize(Policy = "RequireAdminRole")]
@@ -62,6 +79,9 @@ namespace API.Controllers
if (result)
{
+ await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
+ await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
+ await _unitOfWork.CommitAsync();
_taskScheduler.CleanupChapters(chapterIds);
}
return Ok(result);
@@ -119,7 +139,7 @@ namespace API.Controllers
return Ok();
}
- [HttpPost]
+ [HttpPost("update")]
public async Task UpdateSeries(UpdateSeriesDto updateSeries)
{
_logger.LogInformation("{UserName} is updating Series {SeriesName}", User.GetUsername(), updateSeries.Name);
@@ -132,27 +152,39 @@ namespace API.Controllers
{
return BadRequest("A series already exists in this library with this name. Series Names must be unique to a library.");
}
- series.Name = updateSeries.Name;
- series.LocalizedName = updateSeries.LocalizedName;
- series.SortName = updateSeries.SortName;
- series.Summary = updateSeries.Summary;
+ series.Name = updateSeries.Name.Trim();
+ series.LocalizedName = updateSeries.LocalizedName.Trim();
+ series.SortName = updateSeries.SortName?.Trim();
+ series.Summary = updateSeries.Summary?.Trim();
+
+ var needsRefreshMetadata = false;
+ if (series.CoverImageLocked && !updateSeries.CoverImageLocked)
+ {
+ // Trigger a refresh when we are moving from a locked image to a non-locked
+ needsRefreshMetadata = true;
+ series.CoverImageLocked = updateSeries.CoverImageLocked;
+ }
_unitOfWork.SeriesRepository.Update(series);
if (await _unitOfWork.CommitAsync())
{
+ if (needsRefreshMetadata)
+ {
+ _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id);
+ }
return Ok();
}
return BadRequest("There was an error with updating the series");
}
- [HttpGet("recently-added")]
- public async Task>> GetRecentlyAdded([FromQuery] UserParams userParams, int libraryId = 0)
+ [HttpPost("recently-added")]
+ public async Task>> GetRecentlyAdded(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var series =
- await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, user.Id, userParams);
+ await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, user.Id, userParams, filterDto);
// Apply progress/rating information (I can't work out how to do this in initial query)
if (series == null) return BadRequest("Could not get series");
@@ -164,12 +196,20 @@ namespace API.Controllers
return Ok(series);
}
- [HttpGet("in-progress")]
- public async Task>> GetInProgress(int libraryId = 0, int limit = 20)
+ [HttpPost("in-progress")]
+ public async Task>> GetInProgress(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{
+ // NOTE: This has to be done manually like this due to the DistinctBy requirement
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
- if (user == null) return Ok(Array.Empty());
- return Ok(await _unitOfWork.SeriesRepository.GetInProgress(user.Id, libraryId, limit));
+ var results = await _unitOfWork.SeriesRepository.GetInProgress(user.Id, libraryId, userParams, filterDto);
+
+ var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize)
+ .Take(userParams.PageSize).ToList();
+ var pagedList = new PagedList(listResults, listResults.Count, userParams.PageNumber, userParams.PageSize);
+
+ Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages);
+
+ return Ok(pagedList);
}
[Authorize(Policy = "RequireAdminRole")]
@@ -202,6 +242,7 @@ namespace API.Controllers
{
var seriesId = updateSeriesMetadataDto.SeriesMetadata.SeriesId;
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
+ var allTags = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).ToList();
if (series.Metadata == null)
{
series.Metadata = DbFactory.SeriesMetadata(updateSeriesMetadataDto.Tags
@@ -226,13 +267,13 @@ namespace API.Controllers
// At this point, all tags that aren't in dto have been removed.
foreach (var tag in updateSeriesMetadataDto.Tags)
{
- var existingTag = series.Metadata.CollectionTags.SingleOrDefault(t => t.Title == tag.Title);
+ var existingTag = allTags.SingleOrDefault(t => t.Title == tag.Title);
if (existingTag != null)
{
- // Update existingTag
- existingTag.Promoted = tag.Promoted;
- existingTag.Title = tag.Title;
- existingTag.NormalizedTitle = Parser.Parser.Normalize(tag.Title).ToUpper();
+ if (!series.Metadata.CollectionTags.Any(t => t.Title == tag.Title))
+ {
+ newTags.Add(existingTag);
+ }
}
else
{
@@ -257,14 +298,21 @@ namespace API.Controllers
return Ok("Successfully updated");
}
}
- catch (Exception)
+ catch (Exception ex)
{
+ _logger.LogError(ex, "There was an exception when updating metadata");
await _unitOfWork.RollbackAsync();
}
return BadRequest("Could not update metadata");
}
+ ///
+ /// Returns all Series grouped by the passed Collection Id with Pagination.
+ ///
+ /// Collection Id to pull series from
+ /// Pagination information
+ ///
[HttpGet("series-by-collection")]
public async Task>> GetSeriesByCollectionTag(int collectionId, [FromQuery] UserParams userParams)
{
@@ -282,6 +330,19 @@ namespace API.Controllers
return Ok(series);
}
+ ///
+ /// Fetches Series for a set of Ids. This will check User for permission access and filter out any Ids that don't exist or
+ /// the user does not have access to.
+ ///
+ ///
+ [HttpPost("series-by-ids")]
+ public async Task>> GetAllSeriesById(SeriesByIdsDto dto)
+ {
+ if (dto.SeriesIds == null) return BadRequest("Must pass seriesIds");
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
+ return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(dto.SeriesIds, user.Id));
+ }
+
}
}
diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs
index 929f0d0ae..eec7c53cb 100644
--- a/API/Controllers/ServerController.cs
+++ b/API/Controllers/ServerController.cs
@@ -1,7 +1,9 @@
using System;
+using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using API.DTOs.Stats;
+using API.DTOs.Update;
using API.Extensions;
using API.Interfaces.Services;
using API.Services.Tasks;
@@ -23,9 +25,11 @@ namespace API.Controllers
private readonly IBackupService _backupService;
private readonly IArchiveService _archiveService;
private readonly ICacheService _cacheService;
+ private readonly IVersionUpdaterService _versionUpdaterService;
public ServerController(IHostApplicationLifetime applicationLifetime, ILogger logger, IConfiguration config,
- IBackupService backupService, IArchiveService archiveService, ICacheService cacheService)
+ IBackupService backupService, IArchiveService archiveService, ICacheService cacheService,
+ IVersionUpdaterService versionUpdaterService)
{
_applicationLifetime = applicationLifetime;
_logger = logger;
@@ -33,8 +37,13 @@ namespace API.Controllers
_backupService = backupService;
_archiveService = archiveService;
_cacheService = cacheService;
+ _versionUpdaterService = versionUpdaterService;
}
+ ///
+ /// Attempts to Restart the server. Does not work, will shutdown the instance.
+ ///
+ ///
[HttpPost("restart")]
public ActionResult RestartServer()
{
@@ -44,6 +53,10 @@ namespace API.Controllers
return Ok();
}
+ ///
+ /// Performs an ad-hoc cleanup of Cache
+ ///
+ ///
[HttpPost("clear-cache")]
public ActionResult ClearCache()
{
@@ -53,6 +66,19 @@ namespace API.Controllers
return Ok();
}
+ ///
+ /// Performs an ad-hoc backup of the Database
+ ///
+ ///
+ [HttpPost("backup-db")]
+ public ActionResult BackupDatabase()
+ {
+ _logger.LogInformation("{UserName} is backing up database of server from admin dashboard", User.GetUsername());
+ _backupService.BackupDatabase();
+
+ return Ok();
+ }
+
///
/// Returns non-sensitive information about the current system
///
@@ -78,6 +104,16 @@ namespace API.Controllers
}
}
+ [HttpGet("check-update")]
+ public async Task> CheckForUpdates()
+ {
+ return Ok(await _versionUpdaterService.CheckForUpdate());
+ }
+ [HttpGet("changelog")]
+ public async Task>> GetChangelog()
+ {
+ return Ok(await _versionUpdaterService.GetAllReleases());
+ }
}
}
diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs
index d219db790..09fcf9738 100644
--- a/API/Controllers/StatsController.cs
+++ b/API/Controllers/StatsController.cs
@@ -29,12 +29,11 @@ namespace API.Controllers
return Ok();
}
- catch (Exception e)
+ catch (Exception ex)
{
- _logger.LogError(e, "Error updating the usage statistics");
- Console.WriteLine(e);
+ _logger.LogError(ex, "Error updating the usage statistics");
throw;
}
}
}
-}
\ No newline at end of file
+}
diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs
new file mode 100644
index 000000000..0d924c66d
--- /dev/null
+++ b/API/Controllers/UploadController.cs
@@ -0,0 +1,208 @@
+using System;
+using System.Threading.Tasks;
+using API.DTOs.Uploads;
+using API.Interfaces;
+using API.Interfaces.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace API.Controllers
+{
+ ///
+ ///
+ ///
+ [Authorize(Policy = "RequireAdminRole")]
+ public class UploadController : BaseApiController
+ {
+ private readonly IUnitOfWork _unitOfWork;
+ private readonly IImageService _imageService;
+ private readonly ILogger _logger;
+ private readonly ITaskScheduler _taskScheduler;
+
+ ///
+ public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger logger, ITaskScheduler taskScheduler)
+ {
+ _unitOfWork = unitOfWork;
+ _imageService = imageService;
+ _logger = logger;
+ _taskScheduler = taskScheduler;
+ }
+
+ ///
+ /// Replaces series cover image and locks it with a base64 encoded image
+ ///
+ ///
+ ///
+ [Authorize(Policy = "RequireAdminRole")]
+ [RequestSizeLimit(8_000_000)]
+ [HttpPost("series")]
+ public async Task UploadSeriesCoverImageFromUrl(UploadFileDto uploadFileDto)
+ {
+ // Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
+ // See if we can do this all in memory without touching underlying system
+ if (string.IsNullOrEmpty(uploadFileDto.Url))
+ {
+ return BadRequest("You must pass a url to use");
+ }
+
+ try
+ {
+ var bytes = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url);
+ var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(uploadFileDto.Id);
+
+ if (bytes.Length > 0)
+ {
+ series.CoverImage = bytes;
+ series.CoverImageLocked = true;
+ _unitOfWork.SeriesRepository.Update(series);
+ }
+
+ if (_unitOfWork.HasChanges())
+ {
+ await _unitOfWork.CommitAsync();
+ return Ok();
+ }
+
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "There was an issue uploading cover image for Series {Id}", uploadFileDto.Id);
+ await _unitOfWork.RollbackAsync();
+ }
+
+ return BadRequest("Unable to save cover image to Series");
+ }
+
+ ///
+ /// Replaces collection tag cover image and locks it with a base64 encoded image
+ ///
+ ///
+ ///
+ [Authorize(Policy = "RequireAdminRole")]
+ [RequestSizeLimit(8_000_000)]
+ [HttpPost("collection")]
+ public async Task UploadCollectionCoverImageFromUrl(UploadFileDto uploadFileDto)
+ {
+ // Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
+ // See if we can do this all in memory without touching underlying system
+ if (string.IsNullOrEmpty(uploadFileDto.Url))
+ {
+ return BadRequest("You must pass a url to use");
+ }
+
+ try
+ {
+ var bytes = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url);
+ var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(uploadFileDto.Id);
+
+ if (bytes.Length > 0)
+ {
+ tag.CoverImage = bytes;
+ tag.CoverImageLocked = true;
+ _unitOfWork.CollectionTagRepository.Update(tag);
+ }
+
+ if (_unitOfWork.HasChanges())
+ {
+ await _unitOfWork.CommitAsync();
+ return Ok();
+ }
+
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "There was an issue uploading cover image for Collection Tag {Id}", uploadFileDto.Id);
+ await _unitOfWork.RollbackAsync();
+ }
+
+ return BadRequest("Unable to save cover image to Collection Tag");
+ }
+
+ ///
+ /// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image.
+ ///
+ ///
+ ///
+ [Authorize(Policy = "RequireAdminRole")]
+ [RequestSizeLimit(8_000_000)]
+ [HttpPost("chapter")]
+ public async Task UploadChapterCoverImageFromUrl(UploadFileDto uploadFileDto)
+ {
+ // Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
+ // See if we can do this all in memory without touching underlying system
+ if (string.IsNullOrEmpty(uploadFileDto.Url))
+ {
+ return BadRequest("You must pass a url to use");
+ }
+
+ try
+ {
+ var bytes = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url);
+
+ if (bytes.Length > 0)
+ {
+ var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(uploadFileDto.Id);
+ chapter.CoverImage = bytes;
+ chapter.CoverImageLocked = true;
+ _unitOfWork.ChapterRepository.Update(chapter);
+ var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(chapter.VolumeId);
+ volume.CoverImage = chapter.CoverImage;
+ _unitOfWork.VolumeRepository.Update(volume);
+ }
+
+ if (_unitOfWork.HasChanges())
+ {
+ await _unitOfWork.CommitAsync();
+ return Ok();
+ }
+
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "There was an issue uploading cover image for Chapter {Id}", uploadFileDto.Id);
+ await _unitOfWork.RollbackAsync();
+ }
+
+ return BadRequest("Unable to save cover image to Chapter");
+ }
+
+ ///
+ /// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image.
+ ///
+ /// Does not use Url property
+ ///
+ [Authorize(Policy = "RequireAdminRole")]
+ [HttpPost("reset-chapter-lock")]
+ public async Task ResetChapterLock(UploadFileDto uploadFileDto)
+ {
+ try
+ {
+ var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(uploadFileDto.Id);
+ chapter.CoverImage = Array.Empty();
+ chapter.CoverImageLocked = false;
+ _unitOfWork.ChapterRepository.Update(chapter);
+ var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(chapter.VolumeId);
+ volume.CoverImage = chapter.CoverImage;
+ _unitOfWork.VolumeRepository.Update(volume);
+ var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId);
+
+ if (_unitOfWork.HasChanges())
+ {
+ await _unitOfWork.CommitAsync();
+ _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id);
+ return Ok();
+ }
+
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "There was an issue resetting cover lock for Chapter {Id}", uploadFileDto.Id);
+ await _unitOfWork.RollbackAsync();
+ }
+
+ return BadRequest("Unable to resetting cover lock for Chapter");
+ }
+
+ }
+}
diff --git a/API/DTOs/BookmarkDto.cs b/API/DTOs/BookmarkDto.cs
index c06f6d30a..c45a183c3 100644
--- a/API/DTOs/BookmarkDto.cs
+++ b/API/DTOs/BookmarkDto.cs
@@ -2,14 +2,10 @@
{
public class BookmarkDto
{
+ public int Id { get; set; }
+ public int Page { get; set; }
public int VolumeId { get; set; }
- public int ChapterId { get; set; }
- public int PageNum { get; set; }
public int SeriesId { get; set; }
- ///
- /// For Book reader, this can be an optional string of the id of a part marker, to help resume reading position
- /// on pages that combine multiple "chapters".
- ///
- public string BookScrollId { get; set; }
+ public int ChapterId { get; set; }
}
-}
\ No newline at end of file
+}
diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs
index 4dcabee33..d7a4beb64 100644
--- a/API/DTOs/ChapterDto.cs
+++ b/API/DTOs/ChapterDto.cs
@@ -2,6 +2,10 @@
namespace API.DTOs
{
+ ///
+ /// A Chapter is the lowest grouping of a reading medium. A Chapter contains a set of MangaFiles which represents the underlying
+ /// file (abstracted from type).
+ ///
public class ChapterDto
{
public int Id { get; init; }
@@ -10,7 +14,7 @@ namespace API.DTOs
///
public string Range { get; init; }
///
- /// Smallest number of the Range.
+ /// Smallest number of the Range.
///
public string Number { get; init; }
///
@@ -22,7 +26,7 @@ namespace API.DTOs
///
public bool IsSpecial { get; init; }
///
- /// Used for books/specials to display custom title. For non-specials/books, will be set to
+ /// Used for books/specials to display custom title. For non-specials/books, will be set to
///
public string Title { get; init; }
///
@@ -33,6 +37,13 @@ namespace API.DTOs
/// Calculated at API time. Number of pages read for this Chapter for logged in user.
///
public int PagesRead { get; set; }
+ ///
+ /// If the Cover Image is locked for this entity
+ ///
+ public bool CoverImageLocked { get; set; }
+ ///
+ /// Volume Id this Chapter belongs to
+ ///
public int VolumeId { get; init; }
}
-}
\ No newline at end of file
+}
diff --git a/API/DTOs/CollectionTagDto.cs b/API/DTOs/CollectionTagDto.cs
index 26f256562..cb9870610 100644
--- a/API/DTOs/CollectionTagDto.cs
+++ b/API/DTOs/CollectionTagDto.cs
@@ -6,5 +6,6 @@
public string Title { get; set; }
public string Summary { get; set; }
public bool Promoted { get; set; }
+ public bool CoverImageLocked { get; set; }
}
-}
\ No newline at end of file
+}
diff --git a/API/DTOs/Downloads/DownloadBookmarkDto.cs b/API/DTOs/Downloads/DownloadBookmarkDto.cs
new file mode 100644
index 000000000..5239b4aae
--- /dev/null
+++ b/API/DTOs/Downloads/DownloadBookmarkDto.cs
@@ -0,0 +1,9 @@
+using System.Collections.Generic;
+
+namespace API.DTOs.Downloads
+{
+ public class DownloadBookmarkDto
+ {
+ public IEnumerable Bookmarks { get; set; }
+ }
+}
diff --git a/API/DTOs/Filtering/FilterDto.cs b/API/DTOs/Filtering/FilterDto.cs
new file mode 100644
index 000000000..38a172016
--- /dev/null
+++ b/API/DTOs/Filtering/FilterDto.cs
@@ -0,0 +1,10 @@
+using API.Entities.Enums;
+
+namespace API.DTOs.Filtering
+{
+ public class FilterDto
+ {
+ public MangaFormat? MangaFormat { get; init; } = null;
+
+ }
+}
diff --git a/API/DTOs/ProgressDto.cs b/API/DTOs/ProgressDto.cs
new file mode 100644
index 000000000..4810a40a9
--- /dev/null
+++ b/API/DTOs/ProgressDto.cs
@@ -0,0 +1,15 @@
+namespace API.DTOs
+{
+ public class ProgressDto
+ {
+ public int VolumeId { get; set; }
+ public int ChapterId { get; set; }
+ public int PageNum { get; set; }
+ public int SeriesId { get; set; }
+ ///
+ /// For Book reader, this can be an optional string of the id of a part marker, to help resume reading position
+ /// on pages that combine multiple "chapters".
+ ///
+ public string BookScrollId { get; set; }
+ }
+}
diff --git a/API/DTOs/RemoveBookmarkForSeriesDto.cs b/API/DTOs/RemoveBookmarkForSeriesDto.cs
new file mode 100644
index 000000000..7ec76b081
--- /dev/null
+++ b/API/DTOs/RemoveBookmarkForSeriesDto.cs
@@ -0,0 +1,7 @@
+namespace API.DTOs
+{
+ public class RemoveBookmarkForSeriesDto
+ {
+ public int SeriesId { get; init; }
+ }
+}
diff --git a/API/DTOs/SeriesByIdsDto.cs b/API/DTOs/SeriesByIdsDto.cs
new file mode 100644
index 000000000..0ffdd8cba
--- /dev/null
+++ b/API/DTOs/SeriesByIdsDto.cs
@@ -0,0 +1,7 @@
+namespace API.DTOs
+{
+ public class SeriesByIdsDto
+ {
+ public int[] SeriesIds { get; init; }
+ }
+}
diff --git a/API/DTOs/SeriesDto.cs b/API/DTOs/SeriesDto.cs
index 933bf0408..fc70ce5ed 100644
--- a/API/DTOs/SeriesDto.cs
+++ b/API/DTOs/SeriesDto.cs
@@ -12,6 +12,7 @@ namespace API.DTOs
public string SortName { get; init; }
public string Summary { get; init; }
public int Pages { get; init; }
+ public bool CoverImageLocked { get; set; }
///
/// Sum of pages read from linked Volumes. Calculated at API-time.
///
diff --git a/API/DTOs/Update/UpdateNotificationDto.cs b/API/DTOs/Update/UpdateNotificationDto.cs
new file mode 100644
index 000000000..7a16fa18e
--- /dev/null
+++ b/API/DTOs/Update/UpdateNotificationDto.cs
@@ -0,0 +1,38 @@
+namespace API.DTOs.Update
+{
+ ///
+ /// Update Notification denoting a new release available for user to update to
+ ///
+ public class UpdateNotificationDto
+ {
+ ///
+ /// Current installed Version
+ ///
+ public string CurrentVersion { get; init; }
+ ///
+ /// Semver of the release version
+ /// 0.4.3
+ ///
+ public string UpdateVersion { get; init; }
+ ///
+ /// Release body in HTML
+ ///
+ public string UpdateBody { get; init; }
+ ///
+ /// Title of the release
+ ///
+ public string UpdateTitle { get; init; }
+ ///
+ /// Github Url
+ ///
+ public string UpdateUrl { get; init; }
+ ///
+ /// If this install is within Docker
+ ///
+ public bool IsDocker { get; init; }
+ ///
+ /// Is this a pre-release
+ ///
+ public bool IsPrerelease { get; init; }
+ }
+}
diff --git a/API/DTOs/UpdateSeriesDto.cs b/API/DTOs/UpdateSeriesDto.cs
index fac3c209e..39054a032 100644
--- a/API/DTOs/UpdateSeriesDto.cs
+++ b/API/DTOs/UpdateSeriesDto.cs
@@ -10,5 +10,6 @@
public byte[] CoverImage { get; init; }
public int UserRating { get; set; }
public string UserReview { get; set; }
+ public bool CoverImageLocked { get; set; }
}
-}
\ No newline at end of file
+}
diff --git a/API/DTOs/Uploads/UploadFileDto.cs b/API/DTOs/Uploads/UploadFileDto.cs
new file mode 100644
index 000000000..68a5f7de0
--- /dev/null
+++ b/API/DTOs/Uploads/UploadFileDto.cs
@@ -0,0 +1,14 @@
+namespace API.DTOs.Uploads
+{
+ public class UploadFileDto
+ {
+ ///
+ /// Id of the Entity
+ ///
+ public int Id { get; set; }
+ ///
+ /// Url of the file to download from (can be null)
+ ///
+ public string Url { get; set; }
+ }
+}
diff --git a/API/Data/BookmarkRepository.cs b/API/Data/BookmarkRepository.cs
new file mode 100644
index 000000000..af212bc72
--- /dev/null
+++ b/API/Data/BookmarkRepository.cs
@@ -0,0 +1,7 @@
+namespace API.Data
+{
+ public class BookmarkRepository
+ {
+
+ }
+}
diff --git a/API/Data/ChapterRepository.cs b/API/Data/ChapterRepository.cs
new file mode 100644
index 000000000..e3510adc4
--- /dev/null
+++ b/API/Data/ChapterRepository.cs
@@ -0,0 +1,23 @@
+using API.Entities;
+using API.Interfaces.Repositories;
+using Microsoft.EntityFrameworkCore;
+
+namespace API.Data
+{
+ public class ChapterRepository : IChapterRepository
+ {
+ private readonly DataContext _context;
+
+ public ChapterRepository(DataContext context)
+ {
+ _context = context;
+ }
+
+ public void Update(Chapter chapter)
+ {
+ _context.Entry(chapter).State = EntityState.Modified;
+ }
+
+ // TODO: Move over Chapter based queries here
+ }
+}
diff --git a/API/Data/CollectionTagRepository.cs b/API/Data/CollectionTagRepository.cs
index 77cfe70f2..b694b0bb8 100644
--- a/API/Data/CollectionTagRepository.cs
+++ b/API/Data/CollectionTagRepository.cs
@@ -25,12 +25,34 @@ namespace API.Data
{
_context.CollectionTag.Remove(tag);
}
-
+
public void Update(CollectionTag tag)
{
_context.Entry(tag).State = EntityState.Modified;
}
+ ///
+ /// Removes any collection tags without any series
+ ///
+ public async Task RemoveTagsWithoutSeries()
+ {
+ var tagsToDelete = await _context.CollectionTag
+ .Include(c => c.SeriesMetadatas)
+ .Where(c => c.SeriesMetadatas.Count == 0)
+ .ToListAsync();
+ _context.RemoveRange(tagsToDelete);
+
+ return await _context.SaveChangesAsync();
+ }
+
+ public async Task> GetAllTagsAsync()
+ {
+ return await _context.CollectionTag
+ .Select(c => c)
+ .OrderBy(c => c.NormalizedTitle)
+ .ToListAsync();
+ }
+
public async Task> GetAllTagDtosAsync()
{
return await _context.CollectionTag
@@ -40,7 +62,7 @@ namespace API.Data
.ProjectTo(_mapper.ConfigurationProvider)
.ToListAsync();
}
-
+
public async Task> GetAllPromotedTagDtosAsync()
{
return await _context.CollectionTag
@@ -57,7 +79,7 @@ namespace API.Data
.Where(c => c.Id == tagId)
.SingleOrDefaultAsync();
}
-
+
public async Task GetFullTagAsync(int tagId)
{
return await _context.CollectionTag
@@ -69,7 +91,7 @@ namespace API.Data
public async Task> SearchTagDtosAsync(string searchQuery)
{
return await _context.CollectionTag
- .Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%")
+ .Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%")
|| EF.Functions.Like(s.NormalizedTitle, $"%{searchQuery}%"))
.OrderBy(s => s.Title)
.AsNoTracking()
@@ -87,4 +109,4 @@ namespace API.Data
.SingleOrDefaultAsync();
}
}
-}
\ No newline at end of file
+}
diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs
index 008d96ed2..62765f607 100644
--- a/API/Data/DataContext.cs
+++ b/API/Data/DataContext.cs
@@ -1,4 +1,7 @@
using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
using API.Entities;
using API.Entities.Interfaces;
using Microsoft.AspNetCore.Identity;
@@ -8,7 +11,7 @@ using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace API.Data
{
- public sealed class DataContext : IdentityDbContext, AppUserRole, IdentityUserLogin,
IdentityRoleClaim, IdentityUserToken>
{
@@ -17,10 +20,10 @@ namespace API.Data
ChangeTracker.Tracked += OnEntityTracked;
ChangeTracker.StateChanged += OnEntityStateChanged;
}
-
+
public DbSet Library { get; set; }
public DbSet Series { get; set; }
-
+
public DbSet Chapter { get; set; }
public DbSet Volume { get; set; }
public DbSet AppUser { get; set; }
@@ -31,18 +34,19 @@ namespace API.Data
public DbSet AppUserPreferences { get; set; }
public DbSet SeriesMetadata { get; set; }
public DbSet CollectionTag { get; set; }
+ public DbSet AppUserBookmark { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
-
+
builder.Entity()
.HasMany(ur => ur.UserRoles)
.WithOne(u => u.User)
.HasForeignKey(ur => ur.UserId)
.IsRequired();
-
+
builder.Entity()
.HasMany(ur => ur.UserRoles)
.WithOne(u => u.Role)
@@ -50,7 +54,7 @@ namespace API.Data
.IsRequired();
}
-
+
void OnEntityTracked(object sender, EntityTrackedEventArgs e)
{
if (!e.FromQuery && e.Entry.State == EntityState.Added && e.Entry.Entity is IEntityDate entity)
@@ -58,7 +62,7 @@ namespace API.Data
entity.Created = DateTime.Now;
entity.LastModified = DateTime.Now;
}
-
+
}
void OnEntityStateChanged(object sender, EntityStateChangedEventArgs e)
@@ -66,5 +70,48 @@ namespace API.Data
if (e.NewState == EntityState.Modified && e.Entry.Entity is IEntityDate entity)
entity.LastModified = DateTime.Now;
}
+
+ private void OnSaveChanges()
+ {
+ foreach (var saveEntity in ChangeTracker.Entries()
+ .Where(e => e.State == EntityState.Modified)
+ .Select(entry => entry.Entity)
+ .OfType())
+ {
+ saveEntity.OnSavingChanges();
+ }
+ }
+
+ #region SaveChanges overrides
+
+ public override int SaveChanges()
+ {
+ this.OnSaveChanges();
+
+ return base.SaveChanges();
+ }
+
+ public override int SaveChanges(bool acceptAllChangesOnSuccess)
+ {
+ this.OnSaveChanges();
+
+ return base.SaveChanges(acceptAllChangesOnSuccess);
+ }
+
+ public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ this.OnSaveChanges();
+
+ return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
+ }
+
+ public override Task SaveChangesAsync(CancellationToken cancellationToken = default(CancellationToken))
+ {
+ this.OnSaveChanges();
+
+ return base.SaveChangesAsync(cancellationToken);
+ }
+
+ #endregion
}
-}
\ No newline at end of file
+}
diff --git a/API/Data/DbFactory.cs b/API/Data/DbFactory.cs
index 877aa7581..a57ba4037 100644
--- a/API/Data/DbFactory.cs
+++ b/API/Data/DbFactory.cs
@@ -52,7 +52,7 @@ namespace API.Data
IsSpecial = specialTreatment,
};
}
-
+
public static SeriesMetadata SeriesMetadata(ICollection collectionTags)
{
return new SeriesMetadata()
@@ -66,11 +66,11 @@ namespace API.Data
return new CollectionTag()
{
Id = id,
- NormalizedTitle = API.Parser.Parser.Normalize(title).ToUpper(),
- Title = title,
- Summary = summary,
+ NormalizedTitle = API.Parser.Parser.Normalize(title?.Trim()).ToUpper(),
+ Title = title?.Trim(),
+ Summary = summary?.Trim(),
Promoted = promoted
};
}
}
-}
\ No newline at end of file
+}
diff --git a/API/Data/FileRepository.cs b/API/Data/FileRepository.cs
index a90ff4df5..c3234abba 100644
--- a/API/Data/FileRepository.cs
+++ b/API/Data/FileRepository.cs
@@ -20,7 +20,7 @@ namespace API.Data
{
var fileExtensions = await _dbContext.MangaFile
.AsNoTracking()
- .Select(x => x.FilePath)
+ .Select(x => x.FilePath.ToLower())
.Distinct()
.ToArrayAsync();
@@ -32,4 +32,4 @@ namespace API.Data
return uniqueFileTypes;
}
}
-}
\ No newline at end of file
+}
diff --git a/API/Data/Migrations/20210809210326_BookmarkPages.Designer.cs b/API/Data/Migrations/20210809210326_BookmarkPages.Designer.cs
new file mode 100644
index 000000000..b339bbd99
--- /dev/null
+++ b/API/Data/Migrations/20210809210326_BookmarkPages.Designer.cs
@@ -0,0 +1,913 @@
+//
+using System;
+using API.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace API.Data.Migrations
+{
+ [DbContext(typeof(DataContext))]
+ [Migration("20210809210326_BookmarkPages")]
+ partial class BookmarkPages
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "5.0.8");
+
+ modelBuilder.Entity("API.Entities.AppRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("AspNetRoles");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastActive")
+ .HasColumnType("TEXT");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("TEXT");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
+
+ b.ToTable("AspNetUsers");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ 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.AppUserPreferences", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("AutoCloseMenu")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderDarkMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderFontFamily")
+ .HasColumnType("TEXT");
+
+ b.Property("BookReaderFontSize")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderLineSpacing")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderMargin")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderTapToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("PageSplitOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReaderMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("ScalingOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("SiteDarkMode")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId")
+ .IsUnique();
+
+ 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("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("PagesRead")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserProgresses");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserRating", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("INTEGER");
+
+ b.Property("Review")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ 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");
+ });
+
+ modelBuilder.Entity("API.Entities.Chapter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverImage")
+ .HasColumnType("BLOB");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("IsSpecial")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("Number")
+ .HasColumnType("TEXT");
+
+ b.Property("Pages")
+ .HasColumnType("INTEGER");
+
+ b.Property("Range")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasColumnType("TEXT");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("VolumeId");
+
+ b.ToTable("Chapter");
+ });
+
+ modelBuilder.Entity("API.Entities.CollectionTag", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverImage")
+ .HasColumnType("BLOB");
+
+ b.Property("NormalizedTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("Promoted")
+ .HasColumnType("INTEGER");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property("Summary")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Id", "Promoted")
+ .IsUnique();
+
+ b.ToTable("CollectionTag");
+ });
+
+ modelBuilder.Entity("API.Entities.FolderPath", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("LastScanned")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Path")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LibraryId");
+
+ b.ToTable("FolderPath");
+ });
+
+ modelBuilder.Entity("API.Entities.Library", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverImage")
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("Library");
+ });
+
+ modelBuilder.Entity("API.Entities.MangaFile", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("FilePath")
+ .HasColumnType("TEXT");
+
+ b.Property("Format")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("Pages")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChapterId");
+
+ b.ToTable("MangaFile");
+ });
+
+ modelBuilder.Entity("API.Entities.Series", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverImage")
+ .HasColumnType("BLOB");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("Format")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("LocalizedName")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .HasColumnType("TEXT");
+
+ b.Property("OriginalName")
+ .HasColumnType("TEXT");
+
+ b.Property("Pages")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortName")
+ .HasColumnType("TEXT");
+
+ b.Property("Summary")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LibraryId");
+
+ b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format")
+ .IsUnique();
+
+ b.ToTable("Series");
+ });
+
+ modelBuilder.Entity("API.Entities.SeriesMetadata", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SeriesId")
+ .IsUnique();
+
+ b.HasIndex("Id", "SeriesId")
+ .IsUnique();
+
+ b.ToTable("SeriesMetadata");
+ });
+
+ modelBuilder.Entity("API.Entities.ServerSetting", b =>
+ {
+ b.Property("Key")
+ .HasColumnType("INTEGER");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property("Value")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Key");
+
+ b.ToTable("ServerSetting");
+ });
+
+ modelBuilder.Entity("API.Entities.Volume", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverImage")
+ .HasColumnType("BLOB");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Number")
+ .HasColumnType("INTEGER");
+
+ b.Property("Pages")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("Volume");
+ });
+
+ modelBuilder.Entity("AppUserLibrary", b =>
+ {
+ b.Property("AppUsersId")
+ .HasColumnType("INTEGER");
+
+ b.Property("LibrariesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("AppUsersId", "LibrariesId");
+
+ b.HasIndex("LibrariesId");
+
+ b.ToTable("AppUserLibrary");
+ });
+
+ modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
+ {
+ b.Property("CollectionTagsId")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesMetadatasId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("CollectionTagsId", "SeriesMetadatasId");
+
+ b.HasIndex("SeriesMetadatasId");
+
+ b.ToTable("CollectionTagSeriesMetadata");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ClaimType")
+ .HasColumnType("TEXT");
+
+ b.Property("ClaimValue")
+ .HasColumnType("TEXT");
+
+ b.Property("RoleId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ClaimType")
+ .HasColumnType("TEXT");
+
+ b.Property("ClaimValue")
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("TEXT");
+
+ b.Property("ProviderKey")
+ .HasColumnType("TEXT");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("LoginProvider")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Value")
+ .HasColumnType("TEXT");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
+ {
+ b.HasOne("API.Entities.AppUser", "AppUser")
+ .WithMany("Bookmarks")
+ .HasForeignKey("AppUserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("AppUser");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
+ {
+ b.HasOne("API.Entities.AppUser", "AppUser")
+ .WithOne("UserPreferences")
+ .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("AppUser");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserProgress", b =>
+ {
+ b.HasOne("API.Entities.AppUser", "AppUser")
+ .WithMany("Progresses")
+ .HasForeignKey("AppUserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("AppUser");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserRating", b =>
+ {
+ b.HasOne("API.Entities.AppUser", "AppUser")
+ .WithMany("Ratings")
+ .HasForeignKey("AppUserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("AppUser");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserRole", b =>
+ {
+ b.HasOne("API.Entities.AppRole", "Role")
+ .WithMany("UserRoles")
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("API.Entities.AppUser", "User")
+ .WithMany("UserRoles")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Role");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("API.Entities.Chapter", b =>
+ {
+ b.HasOne("API.Entities.Volume", "Volume")
+ .WithMany("Chapters")
+ .HasForeignKey("VolumeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Volume");
+ });
+
+ modelBuilder.Entity("API.Entities.FolderPath", b =>
+ {
+ b.HasOne("API.Entities.Library", "Library")
+ .WithMany("Folders")
+ .HasForeignKey("LibraryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Library");
+ });
+
+ modelBuilder.Entity("API.Entities.MangaFile", b =>
+ {
+ b.HasOne("API.Entities.Chapter", "Chapter")
+ .WithMany("Files")
+ .HasForeignKey("ChapterId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Chapter");
+ });
+
+ modelBuilder.Entity("API.Entities.Series", b =>
+ {
+ b.HasOne("API.Entities.Library", "Library")
+ .WithMany("Series")
+ .HasForeignKey("LibraryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Library");
+ });
+
+ modelBuilder.Entity("API.Entities.SeriesMetadata", b =>
+ {
+ b.HasOne("API.Entities.Series", "Series")
+ .WithOne("Metadata")
+ .HasForeignKey("API.Entities.SeriesMetadata", "SeriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Series");
+ });
+
+ modelBuilder.Entity("API.Entities.Volume", b =>
+ {
+ b.HasOne("API.Entities.Series", "Series")
+ .WithMany("Volumes")
+ .HasForeignKey("SeriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Series");
+ });
+
+ modelBuilder.Entity("AppUserLibrary", b =>
+ {
+ b.HasOne("API.Entities.AppUser", null)
+ .WithMany()
+ .HasForeignKey("AppUsersId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("API.Entities.Library", null)
+ .WithMany()
+ .HasForeignKey("LibrariesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
+ {
+ b.HasOne("API.Entities.CollectionTag", null)
+ .WithMany()
+ .HasForeignKey("CollectionTagsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("API.Entities.SeriesMetadata", null)
+ .WithMany()
+ .HasForeignKey("SeriesMetadatasId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.HasOne("API.Entities.AppRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim