diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 726393b90..627edd9ed 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -1,24 +1,17 @@
name: Bug Report
-description: Create a report to help us improve
-title: ""
+description: Help us make Kavita better for everyone by submitting issues you run into while using the program.
+title: "Put a short summary of what went wrong here"
labels: ["needs-triage"]
-assignees:
body:
- type: markdown
attributes:
- value: |
- Thanks for taking the time to fill out this bug report!
- - type: markdown
- attributes:
- value: |
- If you have a feature request, please submit on [Github Discussions](https://github.com/Kareadita/Kavita/discussions/2529).
+ value: "Thanks for taking the time to fill out this bug report!"
- type: textarea
id: what-happened
attributes:
label: What happened?
- description: Also tell us, what steps you took so we can try to reproduce.
+ description: Don't forget to tell us what steps you took so we can try to reproduce.
placeholder: Tell us what you see!
- value: ""
validations:
required: true
- type: textarea
@@ -26,33 +19,35 @@ body:
attributes:
label: What did you expect?
description: What did you expect to happen?
- placeholder: Tell us what you expected to see!
- value: ""
+ placeholder: Tell us what you expected to see! Go in as much detail as possible so we can confirm if the behavior is something that is broken.
validations:
required: true
- - type: textarea
+ - type: dropdown
id: version
attributes:
- label: Version
- description: What version are you running?
- placeholder: Can be found by going to Server Settings > System
- value: ""
+ label: Kavita Version Number - Don't see your version number listed? Then your install is out of date. Please update and see if your issue still persists.
+ multiple: false
+ options:
+ - 0.7.14 - Stable
+ - Nightly Testing Branch
validations:
required: true
- type: dropdown
id: OS
attributes:
- label: What OS is Kavita being run on?
+ label: What operating system is Kavita being hosted from?
multiple: false
options:
- - Docker
+ - Docker (LSIO Container)
+ - Docker (Dockerhub Container)
+ - Docker (Other)
- Windows
- Linux
- Mac
- type: dropdown
id: desktop-OS
attributes:
- label: If issue being seen on Desktop, what OS are you running where you see the issue?
+ label: If the issue is being seen on Desktop, what OS are you running where you see the issue?
multiple: false
options:
- Windows
@@ -61,17 +56,18 @@ body:
- type: dropdown
id: desktop-browsers
attributes:
- label: If issue being seen in the UI, what browsers are you seeing the problem on?
+ label: If the issue is being seen in the UI, what browsers are you seeing the problem on?
multiple: true
options:
- Firefox
- Chrome
- Safari
- Microsoft Edge
+ - Other (List in "Additional Notes" box)
- type: dropdown
id: mobile-OS
attributes:
- label: If issue being seen on Mobile, what OS are you running where you see the issue?
+ label: If the issue is being seen on Mobile, what OS are you running where you see the issue?
multiple: false
options:
- Android
@@ -79,7 +75,7 @@ body:
- type: dropdown
id: mobile-browsers
attributes:
- label: If issue being seen on UI, what browsers are you seeing the problem on?
+ label: If the issue is being seen on the UI, what browsers are you seeing the problem on?
multiple: true
options:
- Firefox
@@ -97,7 +93,4 @@ body:
attributes:
label: Additional Notes
description: Any other information about the issue not covered in this form?
- placeholder: e.g. Running Kavita on a raspberry pi, updating from X version, using LSIO container, etc
- value: ""
- validations:
- required: true
+ placeholder: e.g. Running Kavita on a Raspberry Pi, updating from X version, using LSIO container, etc
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index ec4bb386b..e9be08116 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1 +1,5 @@
-blank_issues_enabled: false
\ No newline at end of file
+blank_issues_enabled: false
+contact_links:
+ - name: Feature Requests
+ url: https://github.com/Kareadita/Kavita/discussions
+ about: Suggest an idea for the Kavita project
diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index d9fab953f..98ce4c439 100644
--- a/.github/workflows/build-and-test.yml
+++ b/.github/workflows/build-and-test.yml
@@ -26,48 +26,10 @@ jobs:
- name: Install dependencies
run: dotnet restore
- - name: Set up JDK 17
- uses: actions/setup-java@v3
- with:
- distribution: 'zulu'
- java-version: '17'
-
- uses: actions/upload-artifact@v3
with:
name: csproj
path: Kavita.Common/Kavita.Common.csproj
- - name: Cache SonarCloud packages
- uses: actions/cache@v3
- with:
- path: ~\sonar\cache
- key: ${{ runner.os }}-sonar
- restore-keys: ${{ runner.os }}-sonar
-
- - name: Cache SonarCloud scanner
- id: cache-sonar-scanner
- uses: actions/cache@v3
- with:
- path: .\.sonar\scanner
- key: ${{ runner.os }}-sonar-scanner
- restore-keys: ${{ runner.os }}-sonar-scanner
-
- - name: Install SonarCloud scanner
- if: steps.cache-sonar-scanner.outputs.cache-hit != 'true'
- shell: powershell
- run: |
- New-Item -Path .\.sonar\scanner -ItemType Directory
- dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner
-
- - name: Sonar Scan
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
- SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
- shell: powershell
- run: |
- .\.sonar\scanner\dotnet-sonarscanner begin /k:"Kareadita_Kavita" /o:"kareadita" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io"
- dotnet build --configuration Release
- .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
-
- name: Test
run: dotnet test --no-restore --verbosity normal
diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml
index dca370460..ca1314e8b 100644
--- a/.github/workflows/release-workflow.yml
+++ b/.github/workflows/release-workflow.yml
@@ -59,6 +59,13 @@ jobs:
id: parse-body
run: |
body="${{ steps.findPr.outputs.body }}"
+ body=${body//\'/}
+ body=${body//'%'/'%25'}
+ body=${body//$'\n'/'%0A'}
+ body=${body//$'\r'/'%0D'}
+ body=${body//$'`'/'%60'}
+ body=${body//$'>'/'%3E'}
+
if [[ ${#body} -gt 1870 ]] ; then
body=${body:0:1870}
body="${body}...and much more.
@@ -66,16 +73,9 @@ jobs:
Read full changelog: https://github.com/Kareadita/Kavita/releases/latest"
fi
- body=${body//\'/}
- body=${body//'%'/'%25'}
- body=${body//$'\n'/'%0A'}
- body=${body//$'\r'/'%0D'}
- body=${body//$'`'/'%60'}
- body=${body//$'>'/'%3E'}
echo $body
echo "BODY=$body" >> $GITHUB_OUTPUT
-
- name: Check Out Repo
uses: actions/checkout@v3
with:
diff --git a/.gitignore b/.gitignore
index 7da76a034..bb124fc7f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -527,8 +527,7 @@ API/config/stats/*
API/config/stats/app_stats.json
API/config/pre-metadata/
API/config/post-metadata/
-API/config/relations-imported.csv
-API/config/relations.csv
+API/config/*.csv
API.Tests/TestResults/
UI/Web/.vscode/settings.json
/API.Tests/Services/Test Data/ArchiveService/CoverImages/output/*
diff --git a/API.Benchmark/API.Benchmark.csproj b/API.Benchmark/API.Benchmark.csproj
index cbfd1d715..ebc913fe1 100644
--- a/API.Benchmark/API.Benchmark.csproj
+++ b/API.Benchmark/API.Benchmark.csproj
@@ -10,8 +10,8 @@
-
-
+
+
diff --git a/API.Benchmark/TestBenchmark.cs b/API.Benchmark/TestBenchmark.cs
index 0b4880690..3b08bbcdf 100644
--- a/API.Benchmark/TestBenchmark.cs
+++ b/API.Benchmark/TestBenchmark.cs
@@ -25,7 +25,7 @@ public class TestBenchmark
{
list.Add(new VolumeDto()
{
- Number = random.Next(10) > 5 ? 1 : 0,
+ MinNumber = random.Next(10) > 5 ? 1 : 0,
Chapters = GenerateChapters()
});
}
@@ -49,7 +49,7 @@ public class TestBenchmark
private static void SortSpecialChapters(IEnumerable volumes)
{
- foreach (var v in volumes.Where(vDto => vDto.Number == 0))
+ foreach (var v in volumes.Where(vDto => vDto.MinNumber == 0))
{
v.Chapters = v.Chapters.OrderByNatural(x => x.Range).ToList();
}
diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj
index f6ceb75d3..5287a124a 100644
--- a/API.Tests/API.Tests.csproj
+++ b/API.Tests/API.Tests.csproj
@@ -6,13 +6,13 @@
-
+
-
-
-
-
+
+
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
diff --git a/API.Tests/Converters/CronConverterTests.cs b/API.Tests/Converters/CronConverterTests.cs
index 4d26edef7..4e214e8f1 100644
--- a/API.Tests/Converters/CronConverterTests.cs
+++ b/API.Tests/Converters/CronConverterTests.cs
@@ -1,17 +1,20 @@
using API.Helpers.Converters;
+using Hangfire;
using Xunit;
namespace API.Tests.Converters;
-
+#nullable enable
public class CronConverterTests
{
[Theory]
[InlineData("daily", "0 0 * * *")]
[InlineData("disabled", "0 0 31 2 *")]
[InlineData("weekly", "0 0 * * 1")]
- [InlineData("", "0 0 31 2 *")]
- [InlineData("sdfgdf", "")]
- public void ConvertTest(string input, string expected)
+ [InlineData("0 0 31 2 *", "0 0 31 2 *")]
+ [InlineData("sdfgdf", "sdfgdf")]
+ [InlineData("* * * * *", "* * * * *")]
+ [InlineData(null, "0 0 * * *")] // daily
+ public void ConvertTest(string? input, string expected)
{
Assert.Equal(expected, CronConverter.ConvertToCronNotation(input));
}
diff --git a/API.Tests/Extensions/SeriesExtensionsTests.cs b/API.Tests/Extensions/SeriesExtensionsTests.cs
index 6a706e892..c14de4439 100644
--- a/API.Tests/Extensions/SeriesExtensionsTests.cs
+++ b/API.Tests/Extensions/SeriesExtensionsTests.cs
@@ -192,7 +192,7 @@ public class SeriesExtensionsTests
.Build())
.Build())
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("0")
.WithIsSpecial(false)
.WithCoverImage("Volume 1")
@@ -229,7 +229,7 @@ public class SeriesExtensionsTests
.Build())
.Build())
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("0")
.WithIsSpecial(false)
.WithCoverImage("Volume 1")
@@ -266,14 +266,14 @@ public class SeriesExtensionsTests
.Build())
.Build())
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("0")
.WithIsSpecial(false)
.WithCoverImage("Volume 1")
.Build())
.Build())
.WithVolume(new VolumeBuilder("137")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("0")
.WithIsSpecial(false)
.WithCoverImage("Volume 137")
@@ -306,7 +306,7 @@ public class SeriesExtensionsTests
.Build())
.Build())
.WithVolume(new VolumeBuilder("4")
- .WithNumber(4)
+ .WithMinNumber(4)
.WithChapter(new ChapterBuilder("0")
.WithIsSpecial(false)
.WithCoverImage("Volume 4")
diff --git a/API.Tests/Extensions/VolumeListExtensionsTests.cs b/API.Tests/Extensions/VolumeListExtensionsTests.cs
index 2db82eeda..e64267896 100644
--- a/API.Tests/Extensions/VolumeListExtensionsTests.cs
+++ b/API.Tests/Extensions/VolumeListExtensionsTests.cs
@@ -27,7 +27,7 @@ public class VolumeListExtensionsTests
.Build(),
};
- Assert.Equal(volumes[0].Number, volumes.GetCoverImage(MangaFormat.Archive).Number);
+ Assert.Equal(volumes[0].MinNumber, volumes.GetCoverImage(MangaFormat.Archive).MinNumber);
}
[Fact]
diff --git a/API.Tests/Helpers/RateLimiterTests.cs b/API.Tests/Helpers/RateLimiterTests.cs
new file mode 100644
index 000000000..c05ce4e6d
--- /dev/null
+++ b/API.Tests/Helpers/RateLimiterTests.cs
@@ -0,0 +1,79 @@
+using System;
+using API.Helpers;
+using Xunit;
+
+namespace API.Tests.Helpers;
+
+public class RateLimiterTests
+{
+ [Fact]
+ public void AcquireTokens_Successful()
+ {
+ // Arrange
+ var limiter = new RateLimiter(3, TimeSpan.FromSeconds(1));
+
+ // Act & Assert
+ Assert.True(limiter.TryAcquire("test_key"));
+ Assert.True(limiter.TryAcquire("test_key"));
+ Assert.True(limiter.TryAcquire("test_key"));
+ }
+
+ [Fact]
+ public void AcquireTokens_ExceedLimit()
+ {
+ // Arrange
+ var limiter = new RateLimiter(2, TimeSpan.FromSeconds(10), false);
+
+ // Act
+ limiter.TryAcquire("test_key");
+ limiter.TryAcquire("test_key");
+
+ // Assert
+ Assert.False(limiter.TryAcquire("test_key"));
+ }
+
+ [Fact]
+ public void AcquireTokens_Refill()
+ {
+ // Arrange
+ var limiter = new RateLimiter(2, TimeSpan.FromSeconds(1));
+
+ // Act
+ limiter.TryAcquire("test_key");
+ limiter.TryAcquire("test_key");
+
+ // Wait for refill
+ System.Threading.Thread.Sleep(1100);
+
+ // Assert
+ Assert.True(limiter.TryAcquire("test_key"));
+ }
+
+ [Fact]
+ public void AcquireTokens_Refill_WithOff()
+ {
+ // Arrange
+ var limiter = new RateLimiter(2, TimeSpan.FromSeconds(10), false);
+
+ // Act
+ limiter.TryAcquire("test_key");
+ limiter.TryAcquire("test_key");
+
+ // Wait for refill
+ System.Threading.Thread.Sleep(2100);
+
+ // Assert
+ Assert.False(limiter.TryAcquire("test_key"));
+ }
+
+ [Fact]
+ public void AcquireTokens_MultipleKeys()
+ {
+ // Arrange
+ var limiter = new RateLimiter(2, TimeSpan.FromSeconds(1));
+
+ // Act & Assert
+ Assert.True(limiter.TryAcquire("key1"));
+ Assert.True(limiter.TryAcquire("key2"));
+ }
+}
diff --git a/API.Tests/Parser/MangaParserTests.cs b/API.Tests/Parser/MangaParserTests.cs
index 5ba6a35b7..126e781d6 100644
--- a/API.Tests/Parser/MangaParserTests.cs
+++ b/API.Tests/Parser/MangaParserTests.cs
@@ -292,6 +292,8 @@ public class MangaParserTests
[InlineData("Accel World Chapter 001 Volume 002", "1")]
[InlineData("Bleach 001-003", "1-3")]
[InlineData("Accel World Volume 2", "0")]
+ [InlineData("Historys Strongest Disciple Kenichi_v11_c90-98", "90-98")]
+ [InlineData("Historys Strongest Disciple Kenichi c01-c04", "1-4")]
public void ParseChaptersTest(string filename, string expected)
{
Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseChapter(filename));
diff --git a/API.Tests/Services/BookmarkServiceTests.cs b/API.Tests/Services/BookmarkServiceTests.cs
index 25c7bd5de..6a82f457d 100644
--- a/API.Tests/Services/BookmarkServiceTests.cs
+++ b/API.Tests/Services/BookmarkServiceTests.cs
@@ -180,7 +180,7 @@ Substitute.For());
var series = new SeriesBuilder("Test")
.WithFormat(MangaFormat.Epub)
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("0")
.Build())
.Build())
@@ -246,7 +246,7 @@ Substitute.For());
var series = new SeriesBuilder("Test")
.WithFormat(MangaFormat.Epub)
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1")
.Build())
.Build())
@@ -322,7 +322,7 @@ Substitute.For());
var series = new SeriesBuilder("Test")
.WithFormat(MangaFormat.Epub)
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1")
.Build())
.Build())
@@ -375,7 +375,7 @@ Substitute.For());
var series = new SeriesBuilder("Test")
.WithFormat(MangaFormat.Epub)
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1")
.Build())
.Build())
@@ -428,7 +428,7 @@ Substitute.For());
var series = new SeriesBuilder("Test")
.WithFormat(MangaFormat.Epub)
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1")
.Build())
.Build())
diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs
index 0002b4e6a..8c29c5c18 100644
--- a/API.Tests/Services/CleanupServiceTests.cs
+++ b/API.Tests/Services/CleanupServiceTests.cs
@@ -395,7 +395,7 @@ public class CleanupServiceTests : AbstractDbTest
var series = new SeriesBuilder("Test")
.WithFormat(MangaFormat.Epub)
.WithVolume(new VolumeBuilder("0")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(c)
.Build())
.Build();
@@ -488,15 +488,21 @@ public class CleanupServiceTests : AbstractDbTest
var user = new AppUser()
{
UserName = "CleanupWantToRead_ShouldRemoveFullyReadSeries",
- WantToRead = new List()
- {
- s
- }
};
_context.AppUser.Add(user);
await _unitOfWork.CommitAsync();
+ // Add want to read
+ user.WantToRead = new List()
+ {
+ new AppUserWantToRead()
+ {
+ SeriesId = s.Id
+ }
+ };
+ await _unitOfWork.CommitAsync();
+
await _readerService.MarkSeriesAsRead(user, s.Id);
await _unitOfWork.CommitAsync();
diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs
index 1200c3097..3134997ff 100644
--- a/API.Tests/Services/ReaderServiceTests.cs
+++ b/API.Tests/Services/ReaderServiceTests.cs
@@ -136,7 +136,7 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("0")
- .WithNumber(0)
+ .WithMinNumber(0)
.WithChapter(new ChapterBuilder("0")
.WithPages(1)
.Build())
@@ -166,7 +166,7 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("0")
- .WithNumber(0)
+ .WithMinNumber(0)
.WithChapter(new ChapterBuilder("0")
.WithPages(1)
.Build())
@@ -205,7 +205,7 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("0")
- .WithNumber(0)
+ .WithMinNumber(0)
.WithChapter(new ChapterBuilder("0")
.WithPages(1)
.Build())
@@ -260,7 +260,7 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("0")
- .WithNumber(0)
+ .WithMinNumber(0)
.WithChapter(new ChapterBuilder("0")
.WithPages(1)
.Build())
@@ -299,7 +299,7 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("0")
- .WithNumber(0)
+ .WithMinNumber(0)
.WithChapter(new ChapterBuilder("0")
.WithPages(1)
.Build())
@@ -347,19 +347,19 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.Build())
.WithVolume(new VolumeBuilder("2")
- .WithNumber(2)
+ .WithMinNumber(2)
.WithChapter(new ChapterBuilder("21").Build())
.WithChapter(new ChapterBuilder("22").Build())
.Build())
.WithVolume(new VolumeBuilder("3")
- .WithNumber(3)
+ .WithMinNumber(3)
.WithChapter(new ChapterBuilder("31").Build())
.WithChapter(new ChapterBuilder("32").Build())
.Build())
@@ -382,6 +382,40 @@ public class ReaderServiceTests
Assert.Equal("2", actualChapter.Range);
}
+ [Fact]
+ public async Task GetNextChapterIdAsync_ShouldGetNextVolume_WhenUsingRanges()
+ {
+ // V1 -> V2
+ await ResetDb();
+
+ var series = new SeriesBuilder("Test")
+ .WithVolume(new VolumeBuilder("1-2")
+ .WithMinNumber(1)
+ .WithChapter(new ChapterBuilder("0").Build())
+ .Build())
+
+ .WithVolume(new VolumeBuilder("3-4")
+ .WithMinNumber(2)
+ .WithChapter(new ChapterBuilder("1").Build())
+ .Build())
+ .Build();
+ series.Library = new LibraryBuilder("Test Lib", LibraryType.Manga).Build();
+
+ _context.Series.Add(series);
+
+ _context.AppUser.Add(new AppUser()
+ {
+ UserName = "majora2007"
+ });
+
+ await _context.SaveChangesAsync();
+
+ var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 1, 1);
+ var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter);
+ Assert.Equal("3-4", actualChapter.Volume.Name);
+ Assert.Equal("1", actualChapter.Range);
+ }
+
[Fact]
public async Task GetNextChapterIdAsync_ShouldGetNextVolume_OnlyFloats()
{
@@ -432,19 +466,19 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.Build())
.WithVolume(new VolumeBuilder("2")
- .WithNumber(2)
+ .WithMinNumber(2)
.WithChapter(new ChapterBuilder("21").Build())
.WithChapter(new ChapterBuilder("22").Build())
.Build())
.WithVolume(new VolumeBuilder("3")
- .WithNumber(3)
+ .WithMinNumber(3)
.WithChapter(new ChapterBuilder("31").Build())
.WithChapter(new ChapterBuilder("32").Build())
.Build())
@@ -473,19 +507,19 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.Build())
.WithVolume(new VolumeBuilder("1.5")
- .WithNumber(2)
+ .WithMinNumber(2)
.WithChapter(new ChapterBuilder("21").Build())
.WithChapter(new ChapterBuilder("22").Build())
.Build())
.WithVolume(new VolumeBuilder("3")
- .WithNumber(3)
+ .WithMinNumber(3)
.WithChapter(new ChapterBuilder("31").Build())
.WithChapter(new ChapterBuilder("32").Build())
.Build())
@@ -515,13 +549,13 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("0")
- .WithNumber(0)
+ .WithMinNumber(0)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.Build())
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("21").Build())
.WithChapter(new ChapterBuilder("22").Build())
.Build())
@@ -550,18 +584,18 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("0")
- .WithNumber(0)
+ .WithMinNumber(0)
.WithChapter(new ChapterBuilder("66").Build())
.WithChapter(new ChapterBuilder("67").Build())
.Build())
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.Build())
.WithVolume(new VolumeBuilder("2")
- .WithNumber(2)
+ .WithMinNumber(2)
.WithChapter(new ChapterBuilder("0").Build())
.Build())
.Build();
@@ -592,13 +626,13 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.Build())
.WithVolume(new VolumeBuilder("0")
- .WithNumber(0)
+ .WithMinNumber(0)
.WithChapter(new ChapterBuilder("A.cbz").WithIsSpecial(true).Build())
.WithChapter(new ChapterBuilder("B.cbz").WithIsSpecial(true).Build())
.Build())
@@ -624,7 +658,7 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.Build())
@@ -650,7 +684,7 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("0")
- .WithNumber(0)
+ .WithMinNumber(0)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.Build())
@@ -669,6 +703,37 @@ public class ReaderServiceTests
Assert.Equal(-1, nextChapter);
}
+ // This is commented out because, while valid, I can't solve how to make this pass (https://github.com/Kareadita/Kavita/issues/2099)
+ // [Fact]
+ // public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter_NoSpecials_FirstIsVolume()
+ // {
+ // await ResetDb();
+ //
+ // var series = new SeriesBuilder("Test")
+ // .WithVolume(new VolumeBuilder("0")
+ // .WithMinNumber(0)
+ // .WithChapter(new ChapterBuilder("1").Build())
+ // .WithChapter(new ChapterBuilder("2").Build())
+ // .Build())
+ // .WithVolume(new VolumeBuilder("1")
+ // .WithMinNumber(1)
+ // .WithChapter(new ChapterBuilder("0").Build())
+ // .Build())
+ // .Build();
+ // series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build();
+ //
+ // _context.Series.Add(series);
+ // _context.AppUser.Add(new AppUser()
+ // {
+ // UserName = "majora2007"
+ // });
+ //
+ // await _context.SaveChangesAsync();
+ //
+ // var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 3, 1);
+ // Assert.Equal(-1, nextChapter);
+ // }
+
// This is commented out because, while valid, I can't solve how to make this pass
// [Fact]
// public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter_WithSpecials()
@@ -677,14 +742,14 @@ public class ReaderServiceTests
//
// var series = new SeriesBuilder("Test")
// .WithVolume(new VolumeBuilder("0")
- // .WithNumber(0)
+ // .WithMinNumber(0)
// .WithChapter(new ChapterBuilder("1").Build())
// .WithChapter(new ChapterBuilder("2").Build())
// .WithChapter(new ChapterBuilder("0").WithIsSpecial(true).Build())
// .Build())
//
// .WithVolume(new VolumeBuilder("1")
- // .WithNumber(1)
+ // .WithMinNumber(1)
// .WithChapter(new ChapterBuilder("2").Build())
// .Build())
// .Build();
@@ -711,13 +776,13 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.Build())
.WithVolume(new VolumeBuilder("0")
- .WithNumber(0)
+ .WithMinNumber(0)
.WithChapter(new ChapterBuilder("A.cbz").WithIsSpecial(true).Build())
.WithChapter(new ChapterBuilder("B.cbz").WithIsSpecial(true).Build())
.Build())
@@ -747,7 +812,7 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("0")
- .WithNumber(0)
+ .WithMinNumber(0)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.WithChapter(new ChapterBuilder("A.cbz").WithIsSpecial(true).Build())
@@ -778,13 +843,13 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("0")
- .WithNumber(0)
+ .WithMinNumber(0)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.WithChapter(new ChapterBuilder("A.cbz").WithIsSpecial(true).Build())
.Build())
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("0").Build())
.Build())
.Build();
@@ -811,12 +876,12 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.Build())
.WithVolume(new VolumeBuilder("0")
- .WithNumber(0)
+ .WithMinNumber(0)
.WithChapter(new ChapterBuilder("A.cbz").WithIsSpecial(true).Build())
.WithChapter(new ChapterBuilder("B.cbz").WithIsSpecial(true).Build())
.Build())
@@ -846,12 +911,12 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("12").Build())
.Build())
.WithVolume(new VolumeBuilder("2")
- .WithNumber(2)
+ .WithMinNumber(2)
.WithChapter(new ChapterBuilder("12").Build())
.Build())
.Build();
@@ -872,7 +937,7 @@ public class ReaderServiceTests
var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 1, 1);
var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter, ChapterIncludes.Volumes);
- Assert.Equal(2, actualChapter.Volume.Number);
+ Assert.Equal(2, actualChapter.Volume.MinNumber);
}
#endregion
@@ -887,19 +952,19 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.Build())
.WithVolume(new VolumeBuilder("2")
- .WithNumber(2)
+ .WithMinNumber(2)
.WithChapter(new ChapterBuilder("21").Build())
.WithChapter(new ChapterBuilder("22").Build())
.Build())
.WithVolume(new VolumeBuilder("3")
- .WithNumber(3)
+ .WithMinNumber(3)
.WithChapter(new ChapterBuilder("31").Build())
.WithChapter(new ChapterBuilder("32").Build())
.Build())
@@ -930,19 +995,19 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.Build())
.WithVolume(new VolumeBuilder("1.5")
- .WithNumber(2)
+ .WithMinNumber(2)
.WithChapter(new ChapterBuilder("21").Build())
.WithChapter(new ChapterBuilder("22").Build())
.Build())
.WithVolume(new VolumeBuilder("3")
- .WithNumber(3)
+ .WithMinNumber(3)
.WithChapter(new ChapterBuilder("31").Build())
.WithChapter(new ChapterBuilder("32").Build())
.Build())
@@ -1054,13 +1119,13 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.Build())
.WithVolume(new VolumeBuilder("0")
- .WithNumber(0)
+ .WithMinNumber(0)
.WithChapter(new ChapterBuilder("A.cbz").WithIsSpecial(true).Build())
.WithChapter(new ChapterBuilder("B.cbz").WithIsSpecial(true).Build())
.Build())
@@ -1092,7 +1157,7 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.Build())
@@ -1122,7 +1187,7 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("0").Build())
.Build())
.Build();
@@ -1151,13 +1216,13 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("0")
- .WithNumber(0)
+ .WithMinNumber(0)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.Build())
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("0").Build())
.Build())
.Build();
@@ -1186,20 +1251,20 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("0")
- .WithNumber(0)
+ .WithMinNumber(0)
.WithChapter(new ChapterBuilder("5").Build())
.WithChapter(new ChapterBuilder("6").Build())
.WithChapter(new ChapterBuilder("7").Build())
.Build())
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").WithIsSpecial(true).Build())
.WithChapter(new ChapterBuilder("2").WithIsSpecial(true).Build())
.Build())
.WithVolume(new VolumeBuilder("2")
- .WithNumber(2)
+ .WithMinNumber(2)
.WithChapter(new ChapterBuilder("3").WithIsSpecial(true).Build())
.WithChapter(new ChapterBuilder("4").WithIsSpecial(true).Build())
.Build())
@@ -1234,7 +1299,7 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("0")
- .WithNumber(0)
+ .WithMinNumber(0)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.Build())
@@ -1264,12 +1329,12 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.Build())
.WithVolume(new VolumeBuilder("0")
- .WithNumber(0)
+ .WithMinNumber(0)
.WithChapter(new ChapterBuilder("A.cbz").WithIsSpecial(true).Build())
.WithChapter(new ChapterBuilder("B.cbz").WithIsSpecial(true).Build())
.Build())
@@ -1302,12 +1367,12 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("0")
- .WithNumber(0)
+ .WithMinNumber(0)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.Build())
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("21").Build())
.WithChapter(new ChapterBuilder("22").Build())
.Build())
@@ -1340,12 +1405,12 @@ public class ReaderServiceTests
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("12").Build())
.Build())
.WithVolume(new VolumeBuilder("2")
- .WithNumber(2)
+ .WithMinNumber(2)
.WithChapter(new ChapterBuilder("12").Build())
.Build())
.Build();
@@ -1361,7 +1426,7 @@ public class ReaderServiceTests
var nextChapter = await _readerService.GetPrevChapterIdAsync(1, 2, 2, 1);
var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter, ChapterIncludes.Volumes);
- Assert.Equal(1, actualChapter.Volume.Number);
+ Assert.Equal(1, actualChapter.Volume.MinNumber);
}
#endregion
@@ -1622,6 +1687,35 @@ public class ReaderServiceTests
}
+
+ [Fact]
+ public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenHasSpecial()
+ {
+ await ResetDb();
+ var series = new SeriesBuilder("Test")
+ // Loose chapters
+ .WithVolume(new VolumeBuilder("0")
+ .WithChapter(new ChapterBuilder("1").WithPages(1).Build())
+ .WithChapter(new ChapterBuilder("2").WithPages(1).Build())
+ .WithChapter(new ChapterBuilder("Prologue").WithIsSpecial(true).WithPages(1).Build())
+ .Build())
+ .Build();
+ series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build();
+
+ _context.Series.Add(series);
+
+ _context.AppUser.Add(new AppUser()
+ {
+ UserName = "majora2007"
+ });
+
+ await _context.SaveChangesAsync();
+
+ var nextChapter = await _readerService.GetContinuePoint(1, 1);
+
+ Assert.Equal("1", nextChapter.Range);
+ }
+
[Fact]
public async Task GetContinuePoint_ShouldReturnFirstSpecial()
{
diff --git a/API.Tests/Services/ReadingListServiceTests.cs b/API.Tests/Services/ReadingListServiceTests.cs
index c6d9675d0..23de53674 100644
--- a/API.Tests/Services/ReadingListServiceTests.cs
+++ b/API.Tests/Services/ReadingListServiceTests.cs
@@ -759,7 +759,7 @@ public class ReadingListServiceTests
var fablesSeries = new SeriesBuilder("Fables").Build();
fablesSeries.Volumes.Add(
new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithName("2002")
.WithChapter(new ChapterBuilder("1").Build())
.Build()
@@ -937,7 +937,7 @@ public class ReadingListServiceTests
var fables2Series = new SeriesBuilder("Fables: The Last Castle").Build();
fablesSeries.Volumes.Add(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithName("2002")
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
@@ -945,7 +945,7 @@ public class ReadingListServiceTests
.Build()
);
fables2Series.Volumes.Add(new VolumeBuilder("1")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithName("2003")
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
@@ -980,13 +980,13 @@ public class ReadingListServiceTests
var fables2Series = new SeriesBuilder("Fablesa: The Last Castle").Build();
fablesSeries.Volumes.Add(new VolumeBuilder("2002")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.WithChapter(new ChapterBuilder("3").Build())
.Build());
fables2Series.Volumes.Add(new VolumeBuilder("2003")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.WithChapter(new ChapterBuilder("3").Build())
@@ -1036,7 +1036,7 @@ public class ReadingListServiceTests
// Mock up our series
var fablesSeries = new SeriesBuilder("Fables")
.WithVolume(new VolumeBuilder("2002")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.WithChapter(new ChapterBuilder("3").Build())
@@ -1045,7 +1045,7 @@ public class ReadingListServiceTests
var fables2Series = new SeriesBuilder("Fables: The Last Castle")
.WithVolume(new VolumeBuilder("2003")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.WithChapter(new ChapterBuilder("3").Build())
@@ -1094,13 +1094,13 @@ public class ReadingListServiceTests
var fables2Series = new SeriesBuilder("Fables: The Last Castle").Build();
fablesSeries.Volumes.Add(new VolumeBuilder("2002")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.WithChapter(new ChapterBuilder("3").Build())
.Build());
fables2Series.Volumes.Add(new VolumeBuilder("2003")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.WithChapter(new ChapterBuilder("3").Build())
@@ -1153,13 +1153,13 @@ public class ReadingListServiceTests
var fables2Series = new SeriesBuilder("Fables: The Last Castle").Build();
fablesSeries.Volumes.Add(new VolumeBuilder("2002")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.WithChapter(new ChapterBuilder("3").Build())
.Build());
fables2Series.Volumes.Add(new VolumeBuilder("2003")
- .WithNumber(1)
+ .WithMinNumber(1)
.WithChapter(new ChapterBuilder("1").Build())
.WithChapter(new ChapterBuilder("2").Build())
.WithChapter(new ChapterBuilder("3").Build())
diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs
index ed5a729ad..97a4306d3 100644
--- a/API.Tests/Services/SeriesServiceTests.cs
+++ b/API.Tests/Services/SeriesServiceTests.cs
@@ -18,6 +18,7 @@ using API.Services;
using API.Services.Plus;
using API.SignalR;
using API.Tests.Helpers;
+using EasyCaching.Core;
using Hangfire;
using Hangfire.InMemory;
using Microsoft.Extensions.Caching.Memory;
@@ -1391,4 +1392,96 @@ public class SeriesServiceTests : AbstractDbTest
}
#endregion
+
+ #region DeleteMultipleSeries
+
+ [Fact]
+ public async Task DeleteMultipleSeries_ShouldDeleteSeries()
+ {
+ await ResetDb();
+ var lib1 = new LibraryBuilder("Test LIb")
+ .WithSeries(new SeriesBuilder("Test Series")
+ .WithMetadata(new SeriesMetadata()
+ {
+ AgeRating = AgeRating.Everyone
+ })
+ .WithVolume(new VolumeBuilder("0")
+ .WithChapter(new ChapterBuilder("1").WithFile(
+ new MangaFileBuilder($"{DataDirectory}1.zip", MangaFormat.Archive)
+ .WithPages(1)
+ .Build()
+ ).Build())
+ .Build())
+ .Build())
+ .WithSeries(new SeriesBuilder("Test Series Prequels").Build())
+ .WithSeries(new SeriesBuilder("Test Series Sequels").Build())
+ .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build())
+ .Build();
+ _context.Library.Add(lib1);
+
+ var lib2 = new LibraryBuilder("Test LIb 2", LibraryType.Book)
+ .WithSeries(new SeriesBuilder("Test Series 2").Build())
+ .WithSeries(new SeriesBuilder("Test Series Prequels 2").Build())
+ .WithSeries(new SeriesBuilder("Test Series Prequels 2").Build())// TODO: Is this a bug
+ .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build())
+ .Build();
+ _context.Library.Add(lib2);
+
+ await _context.SaveChangesAsync();
+
+ var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1,
+ SeriesIncludes.Related | SeriesIncludes.ExternalRatings);
+ // Add relations
+ var addRelationDto = CreateRelationsDto(series1);
+ addRelationDto.Adaptations.Add(4); // cross library link
+ await _seriesService.UpdateRelatedSeries(addRelationDto);
+
+
+ // Setup External Metadata stuff
+ series1.ExternalSeriesMetadata ??= new ExternalSeriesMetadata();
+ series1.ExternalSeriesMetadata.ExternalRatings = new List()
+ {
+ new ExternalRating()
+ {
+ SeriesId = 1,
+ Provider = ScrobbleProvider.Mal,
+ AverageScore = 1
+ }
+ };
+ series1.ExternalSeriesMetadata.ExternalRecommendations = new List()
+ {
+ new ExternalRecommendation()
+ {
+ SeriesId = 2,
+ Name = "Series 2",
+ Url = "",
+ CoverUrl = ""
+ },
+ new ExternalRecommendation()
+ {
+ SeriesId = 0, // Causes a FK constraint
+ Name = "Series 2",
+ Url = "",
+ CoverUrl = ""
+ }
+ };
+ series1.ExternalSeriesMetadata.ExternalReviews = new List()
+ {
+ new ExternalReview()
+ {
+ Body = "",
+ Provider = ScrobbleProvider.Mal,
+ BodyJustText = ""
+ }
+ };
+
+ await _context.SaveChangesAsync();
+
+ // Ensure we can delete the series
+ Assert.True(await _seriesService.DeleteMultipleSeries(new[] {1, 2}));
+ Assert.Null(await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1));
+ Assert.Null(await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2));
+ }
+
+ #endregion
}
diff --git a/API/API.csproj b/API/API.csproj
index 8ce0a6a57..aee5fa856 100644
--- a/API/API.csproj
+++ b/API/API.csproj
@@ -53,7 +53,9 @@
-
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
@@ -63,28 +65,27 @@
-
-
+
+
-
-
-
+
+
-
+
-
-
-
-
+
+
+
+
-
-
+
+
-
+
@@ -92,17 +93,17 @@
-
-
-
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
-
+
+
+
+
@@ -190,6 +191,9 @@
+
+ Always
+
diff --git a/API/Constants/CacheProfiles.cs b/API/Constants/CacheProfiles.cs
index 9c702a0d8..ee2cd204e 100644
--- a/API/Constants/CacheProfiles.cs
+++ b/API/Constants/CacheProfiles.cs
@@ -16,11 +16,7 @@ public static class EasyCacheProfiles
///
public const string Library = "library";
///
- /// Metadata filter
+ /// External Series metadata for Kavita+ recommendation
///
- public const string Filter = "filter";
- public const string KavitaPlusReviews = "kavita+reviews";
- public const string KavitaPlusRecommendations = "kavita+recommendations";
- public const string KavitaPlusRatings = "kavita+ratings";
public const string KavitaPlusExternalSeries = "kavita+externalSeries";
}
diff --git a/API/Constants/PolicyConstants.cs b/API/Constants/PolicyConstants.cs
index 69de1821b..de2cf0394 100644
--- a/API/Constants/PolicyConstants.cs
+++ b/API/Constants/PolicyConstants.cs
@@ -35,7 +35,13 @@ public static class PolicyConstants
/// Used to give a user ability to Login to their account
///
public const string LoginRole = "Login";
+ ///
+ /// Restricts the ability to manage their account without an admin
+ ///
+ /// This is used explicitly for Demo Server. Not sure why it would be used in another fashion
+ public const string ReadOnlyRole = "Read Only";
+
public static readonly ImmutableArray ValidRoles =
- ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole);
+ ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole, ReadOnlyRole);
}
diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs
index c220eb6c0..ab8c19d10 100644
--- a/API/Controllers/AccountController.cs
+++ b/API/Controllers/AccountController.cs
@@ -77,10 +77,11 @@ public class AccountController : BaseApiController
[HttpPost("reset-password")]
public async Task UpdatePassword(ResetPasswordDto resetPasswordDto)
{
- _logger.LogInformation("{UserName} is changing {ResetUser}'s password", User.GetUsername(), resetPasswordDto.UserName);
-
var user = await _userManager.Users.SingleOrDefaultAsync(x => x.UserName == resetPasswordDto.UserName);
if (user == null) return Ok(); // Don't report BadRequest as that would allow brute forcing to find accounts on system
+ _logger.LogInformation("{UserName} is changing {ResetUser}'s password", User.GetUsername(), resetPasswordDto.UserName);
+ if (User.IsInRole(PolicyConstants.ReadOnlyRole))
+ return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
var isAdmin = User.IsInRole(PolicyConstants.AdminRole);
if (resetPasswordDto.UserName == User.GetUsername() && !(User.IsInRole(PolicyConstants.ChangePasswordRole) || isAdmin))
@@ -187,12 +188,14 @@ public class AccountController : BaseApiController
{
user = await _userManager.Users
.Include(u => u.UserPreferences)
+ .AsSplitQuery()
.SingleOrDefaultAsync(x => x.ApiKey == loginDto.ApiKey);
}
else
{
user = await _userManager.Users
.Include(u => u.UserPreferences)
+ .AsSplitQuery()
.SingleOrDefaultAsync(x => x.NormalizedUserName == loginDto.Username.ToUpperInvariant());
}
@@ -319,6 +322,7 @@ public class AccountController : BaseApiController
public async Task> ResetApiKey()
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()) ?? throw new KavitaUnauthenticatedUserException();
+ if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
user.ApiKey = HashUtil.ApiKey();
if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync())
@@ -334,7 +338,9 @@ public class AccountController : BaseApiController
///
- /// Initiates the flow to update a user's email address. The email address is not changed in this API. A confirmation link is sent/dumped which will
+ /// Initiates the flow to update a user's email address.
+ ///
+ /// If email is not setup, then the email address is not changed in this API. A confirmation link is sent/dumped which will
/// validate the email. It must be confirmed for the email to update.
///
///
@@ -343,7 +349,7 @@ public class AccountController : BaseApiController
public async Task UpdateEmail(UpdateEmailDto? dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
- if (user == null) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
+ if (user == null || User.IsInRole(PolicyConstants.ReadOnlyRole)) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
if (dto == null || string.IsNullOrEmpty(dto.Email) || string.IsNullOrEmpty(dto.Password))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-payload"));
@@ -374,14 +380,26 @@ public class AccountController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generate-token"));
}
- user.EmailConfirmed = false;
+ var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
+ var shouldEmailUser = serverSettings.IsEmailSetup() || !_emailService.IsValidEmail(user.Email);
+ user.EmailConfirmed = !shouldEmailUser;
user.ConfirmationToken = token;
await _userManager.UpdateAsync(user);
+ if (!shouldEmailUser)
+ {
+ return Ok(new InviteUserResponse
+ {
+ EmailLink = string.Empty,
+ EmailSent = false
+ });
+ }
+
+
// Send a confirmation email
try
{
- var emailLink = await _accountService.GenerateEmailLink(Request, user.ConfirmationToken, "confirm-email-update", dto.Email);
+ var emailLink = await _emailService.GenerateEmailLink(Request, user.ConfirmationToken, "confirm-email-update", dto.Email);
_logger.LogCritical("[Update Email]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
if (!_emailService.IsValidEmail(user.Email))
@@ -396,30 +414,27 @@ public class AccountController : BaseApiController
}
- var accessible = await _accountService.CheckIfAccessible(Request);
- if (accessible)
+ try
{
- try
+ var invitingUser = (await _unitOfWork.UserRepository.GetAdminUsersAsync()).First().UserName!;
+ // Email the old address of the update change
+ BackgroundJob.Enqueue(() => _emailService.SendEmailChangeEmail(new ConfirmationEmailDto()
{
- // Email the old address of the update change
- await _emailService.SendEmailChangeEmail(new ConfirmationEmailDto()
- {
- EmailAddress = string.IsNullOrEmpty(user.Email) ? dto.Email : user.Email,
- InstallId = BuildInfo.Version.ToString(),
- InvitingUser = (await _unitOfWork.UserRepository.GetAdminUsersAsync()).First().UserName!,
- ServerConfirmationLink = emailLink
- });
- }
- catch (Exception)
- {
- /* Swallow exception */
- }
+ EmailAddress = string.IsNullOrEmpty(user.Email) ? dto.Email : user.Email,
+ InstallId = BuildInfo.Version.ToString(),
+ InvitingUser = invitingUser,
+ ServerConfirmationLink = emailLink
+ }));
+ }
+ catch (Exception)
+ {
+ /* Swallow exception */
}
return Ok(new InviteUserResponse
{
EmailLink = string.Empty,
- EmailSent = accessible
+ EmailSent = true
});
}
catch (Exception ex)
@@ -439,7 +454,7 @@ public class AccountController : BaseApiController
if (user == null) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
- if (!await _accountService.HasChangeRestrictionRole(user)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
+ if (!await _accountService.CanChangeAgeRestriction(user)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
user.AgeRestriction = isAdmin ? AgeRating.NotApplicable : dto.AgeRating;
user.AgeRestrictionIncludeUnknowns = isAdmin || dto.IncludeUnknowns;
@@ -574,13 +589,12 @@ public class AccountController : BaseApiController
if (string.IsNullOrEmpty(user.ConfirmationToken))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "manual-setup-fail"));
- return await _accountService.GenerateEmailLink(Request, user.ConfirmationToken, "confirm-email", user.Email!, withBaseUrl);
+ return await _emailService.GenerateEmailLink(Request, user.ConfirmationToken, "confirm-email", user.Email!, withBaseUrl);
}
///
- /// Invites a user to the server. Will generate a setup link for continuing setup. If the server is not accessible, no
- /// email will be sent.
+ /// Invites a user to the server. Will generate a setup link for continuing setup. If email is not setup, a link will be presented to user to continue setup.
///
///
///
@@ -679,15 +693,15 @@ public class AccountController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-invite-user"));
}
-
try
{
- var emailLink = await _accountService.GenerateEmailLink(Request, user.ConfirmationToken, "confirm-email", dto.Email);
+ var emailLink = await _emailService.GenerateEmailLink(Request, user.ConfirmationToken, "confirm-email", dto.Email);
_logger.LogCritical("[Invite User]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
- if (!_emailService.IsValidEmail(dto.Email))
+ var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
+ if (!_emailService.IsValidEmail(dto.Email) || !settings.IsEmailSetup())
{
- _logger.LogInformation("[Invite User] {Email} doesn't appear to be an email, so will not send an email to address", dto.Email.Replace(Environment.NewLine, string.Empty));
+ _logger.LogInformation("[Invite User] {Email} doesn't appear to be an email or email is not setup", dto.Email.Replace(Environment.NewLine, string.Empty));
return Ok(new InviteUserResponse
{
EmailLink = emailLink,
@@ -696,22 +710,17 @@ public class AccountController : BaseApiController
});
}
- var accessible = await _accountService.CheckIfAccessible(Request);
- if (accessible)
+ BackgroundJob.Enqueue(() => _emailService.SendInviteEmail(new ConfirmationEmailDto()
{
- // Do the email send on a background thread to ensure UI can move forward without having to wait for a timeout when users use fake emails
- BackgroundJob.Enqueue(() => _emailService.SendConfirmationEmail(new ConfirmationEmailDto()
- {
- EmailAddress = dto.Email,
- InvitingUser = adminUser.UserName,
- ServerConfirmationLink = emailLink
- }));
- }
+ EmailAddress = dto.Email,
+ InvitingUser = adminUser.UserName,
+ ServerConfirmationLink = emailLink
+ }));
return Ok(new InviteUserResponse
{
EmailLink = emailLink,
- EmailSent = accessible
+ EmailSent = true
});
}
catch (Exception ex)
@@ -837,7 +846,6 @@ public class AccountController : BaseApiController
await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate,
MessageFactory.UserUpdateEvent(user.Id, user.UserName!), user.Id);
- // Perform Login code
return Ok();
}
@@ -882,6 +890,10 @@ public class AccountController : BaseApiController
[EnableRateLimiting("Authentication")]
public async Task> ForgotPassword([FromQuery] string email)
{
+
+ var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
+ if (!settings.IsEmailSetup()) return Ok(await _localizationService.Get("en", "email-not-enabled"));
+
var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email);
if (user == null)
{
@@ -890,32 +902,34 @@ public class AccountController : BaseApiController
}
var roles = await _userManager.GetRolesAsync(user);
- if (!roles.Any(r => r is PolicyConstants.AdminRole or PolicyConstants.ChangePasswordRole))
+ if (!roles.Any(r => r is PolicyConstants.AdminRole or PolicyConstants.ChangePasswordRole or PolicyConstants.ReadOnlyRole))
return Unauthorized(await _localizationService.Translate(user.Id, "permission-denied"));
if (string.IsNullOrEmpty(user.Email) || !user.EmailConfirmed)
return BadRequest(await _localizationService.Translate(user.Id, "confirm-email"));
- var token = await _userManager.GeneratePasswordResetTokenAsync(user);
- var emailLink = await _accountService.GenerateEmailLink(Request, token, "confirm-reset-password", user.Email);
- _logger.LogCritical("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
if (!_emailService.IsValidEmail(user.Email))
{
- _logger.LogCritical("[Forgot Password]: User is trying to do a forgot password flow, but their email ({Email}) isn't valid. No email will be send", user.Email);
+ _logger.LogCritical("[Forgot Password]: User is trying to do a forgot password flow, but their email ({Email}) isn't valid. No email will be send. Admin must change it in UI", user.Email);
return Ok(await _localizationService.Translate(user.Id, "invalid-email"));
}
- if (await _accountService.CheckIfAccessible(Request))
- {
- await _emailService.SendPasswordResetEmail(new PasswordResetEmailDto()
- {
- EmailAddress = user.Email,
- ServerConfirmationLink = emailLink,
- InstallId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value
- });
- return Ok(await _localizationService.Translate(user.Id, "email-sent"));
- }
- return Ok(await _localizationService.Translate(user.Id, "not-accessible-password"));
+ var token = await _userManager.GeneratePasswordResetTokenAsync(user);
+ var emailLink = await _emailService.GenerateEmailLink(Request, token, "confirm-reset-password", user.Email);
+ user.ConfirmationToken = token;
+ _unitOfWork.UserRepository.Update(user);
+ await _unitOfWork.CommitAsync();
+ _logger.LogCritical("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
+
+ var installId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value;
+ BackgroundJob.Enqueue(() => _emailService.SendForgotPasswordEmail(new PasswordResetEmailDto()
+ {
+ EmailAddress = user.Email,
+ ServerConfirmationLink = emailLink,
+ InstallId = installId
+ }));
+
+ return Ok(await _localizationService.Translate(user.Id, "email-sent"));
}
[HttpGet("email-confirmed")]
@@ -963,9 +977,10 @@ public class AccountController : BaseApiController
///
///
///
+ [Authorize("RequireAdminRole")]
[HttpPost("resend-confirmation-email")]
[EnableRateLimiting("Authentication")]
- public async Task> ResendConfirmationSendEmail([FromQuery] int userId)
+ public async Task> ResendConfirmationSendEmail([FromQuery] int userId)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user == null) return BadRequest(await _localizationService.Get("en", "no-user"));
@@ -976,96 +991,47 @@ public class AccountController : BaseApiController
if (user.EmailConfirmed) return BadRequest(await _localizationService.Translate(user.Id, "user-already-confirmed"));
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
- var emailLink = await _accountService.GenerateEmailLink(Request, token, "confirm-email", user.Email);
+ user.ConfirmationToken = token;
+ _unitOfWork.UserRepository.Update(user);
+ await _unitOfWork.CommitAsync();
+ var emailLink = await _emailService.GenerateEmailLink(Request, token, "confirm-email-update", user.Email);
_logger.LogCritical("[Email Migration]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
if (!_emailService.IsValidEmail(user.Email))
{
_logger.LogCritical("[Email Migration]: User {UserName} is trying to resend an invite flow, but their email ({Email}) isn't valid. No email will be send", user.UserName, user.Email);
- return BadRequest(await _localizationService.Translate(user.Id, "invalid-email"));
}
- if (await _accountService.CheckIfAccessible(Request))
+
+ var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
+ var shouldEmailUser = serverSettings.IsEmailSetup() || !_emailService.IsValidEmail(user.Email);
+
+ if (!shouldEmailUser)
{
- try
+ return Ok(new InviteUserResponse()
{
- await _emailService.SendMigrationEmail(new EmailMigrationDto()
- {
- EmailAddress = user.Email!,
- Username = user.UserName!,
- ServerConfirmationLink = emailLink,
- InstallId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value
- });
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "There was an issue resending invite email");
- return BadRequest(await _localizationService.Translate(user.Id, "generic-invite-email"));
- }
- return Ok(emailLink);
+ EmailLink = emailLink,
+ EmailSent = false,
+ InvalidEmail = !_emailService.IsValidEmail(user.Email)
+ });
}
- return BadRequest(await _localizationService.Translate(user.Id, "not-accessible"));
+ BackgroundJob.Enqueue(() => _emailService.SendInviteEmail(new ConfirmationEmailDto()
+ {
+ EmailAddress = user.Email!,
+ InvitingUser = User.GetUsername(),
+ ServerConfirmationLink = emailLink,
+ InstallId = serverSettings.InstallId
+ }));
+
+ return Ok(new InviteUserResponse()
+ {
+ EmailLink = emailLink,
+ EmailSent = true,
+ InvalidEmail = !_emailService.IsValidEmail(user.Email)
+ });
}
- ///
- /// This is similar to invite. Essentially we authenticate the user's password then go through invite email flow
- ///
- ///
- ///
- [AllowAnonymous]
- [HttpPost("migrate-email")]
- public async Task> MigrateEmail(MigrateUserEmailDto dto)
- {
- // If there is an admin account already, return
- var users = await _unitOfWork.UserRepository.GetAdminUsersAsync();
- if (users.Any()) return BadRequest(await _localizationService.Get("en", "admin-already-exists"));
-
- // Check if there is an existing invite
- var emailValidationErrors = await _accountService.ValidateEmail(dto.Email);
- if (emailValidationErrors.Any())
- {
- var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
- if (await _userManager.IsEmailConfirmedAsync(invitedUser!))
- return BadRequest(await _localizationService.Get("en", "user-already-registered", invitedUser!.UserName));
-
- _logger.LogInformation("A user is attempting to login, but hasn't accepted email invite");
- return BadRequest(await _localizationService.Get("en", "user-already-invited"));
- }
-
-
- var user = await _userManager.Users
- .Include(u => u.UserPreferences)
- .SingleOrDefaultAsync(x => x.NormalizedUserName == dto.Username.ToUpper());
- if (user == null) return BadRequest(await _localizationService.Get("en", "invalid-username"));
-
- var validPassword = await _signInManager.UserManager.CheckPasswordAsync(user, dto.Password);
- if (!validPassword) return BadRequest(await _localizationService.Get("en", "bad-credentials"));
-
- try
- {
- var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
-
- user.Email = dto.Email;
- if (!await ConfirmEmailToken(token, user)) return BadRequest(await _localizationService.Get("en", "critical-email-migration"));
- _unitOfWork.UserRepository.Update(user);
-
- await _unitOfWork.CommitAsync();
-
- return Ok();
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "There was an issue during email migration. Contact support");
- _unitOfWork.UserRepository.Delete(user);
- await _unitOfWork.CommitAsync();
- }
-
- return BadRequest(await _localizationService.Get("en", "critical-email-migration"));
- }
-
-
-
private async Task ConfirmEmailToken(string token, AppUser user)
{
var result = await _userManager.ConfirmEmailAsync(user, token);
diff --git a/API/Controllers/DeviceController.cs b/API/Controllers/DeviceController.cs
index 3e1b57fec..61a847b6e 100644
--- a/API/Controllers/DeviceController.cs
+++ b/API/Controllers/DeviceController.cs
@@ -92,18 +92,28 @@ public class DeviceController : BaseApiController
return Ok(await _unitOfWork.DeviceRepository.GetDevicesForUserAsync(User.GetUserId()));
}
+ ///
+ /// Sends a collection of chapters to the user's device
+ ///
+ ///
+ ///
[HttpPost("send-to")]
public async Task SendToDevice(SendToDeviceDto dto)
{
if (dto.ChapterIds.Any(i => i < 0)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "ChapterIds"));
if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "DeviceId"));
- if (await _emailService.IsDefaultEmailService())
+ var isEmailSetup = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetupForSendToDevice();
+ if (!isEmailSetup)
return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email"));
+ // // Validate that the device belongs to the user
+ var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Devices);
+ if (user == null || user.Devices.All(d => d.Id != dto.DeviceId)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-unallowed"));
+
var userId = User.GetUserId();
await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress,
- MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"),
+ MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(userId, "send-to-device-status"),
"started"), userId);
try
{
@@ -112,16 +122,16 @@ public class DeviceController : BaseApiController
}
catch (KavitaException ex)
{
- return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message));
+ return BadRequest(await _localizationService.Translate(userId, ex.Message));
}
finally
{
- await _eventHub.SendMessageToAsync(MessageFactory.SendingToDevice,
- MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"),
+ await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress,
+ MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(userId, "send-to-device-status"),
"ended"), userId);
}
- return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-send-to"));
+ return BadRequest(await _localizationService.Translate(userId, "generic-send-to"));
}
@@ -132,7 +142,8 @@ public class DeviceController : BaseApiController
if (dto.SeriesId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "SeriesId"));
if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "DeviceId"));
- if (await _emailService.IsDefaultEmailService())
+ var isEmailSetup = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetupForSendToDevice();
+ if (!isEmailSetup)
return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email"));
var userId = User.GetUserId();
@@ -156,7 +167,7 @@ public class DeviceController : BaseApiController
}
finally
{
- await _eventHub.SendMessageToAsync(MessageFactory.SendingToDevice,
+ await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress,
MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"),
"ended"), userId);
}
@@ -164,8 +175,6 @@ public class DeviceController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-send-to"));
}
-
-
}
diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs
index b983a2d5c..05fd7ea27 100644
--- a/API/Controllers/DownloadController.cs
+++ b/API/Controllers/DownloadController.cs
@@ -103,7 +103,7 @@ public class DownloadController : BaseApiController
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId);
try
{
- return await DownloadFiles(files, $"download_{User.GetUsername()}_v{volumeId}", $"{series!.Name} - Volume {volume.Number}.zip");
+ return await DownloadFiles(files, $"download_{User.GetUsername()}_v{volumeId}", $"{series!.Name} - Volume {volume.Name}.zip");
}
catch (KavitaException ex)
{
@@ -118,7 +118,7 @@ public class DownloadController : BaseApiController
return await _accountService.HasDownloadPermission(user);
}
- private ActionResult GetFirstFileDownload(IEnumerable files)
+ private PhysicalFileResult GetFirstFileDownload(IEnumerable files)
{
var (zipFile, contentType, fileDownloadName) = _downloadService.GetFirstFileDownload(files);
return PhysicalFile(zipFile, contentType, Uri.EscapeDataString(fileDownloadName), true);
@@ -150,31 +150,40 @@ public class DownloadController : BaseApiController
private async Task DownloadFiles(ICollection files, string tempFolder, string downloadName)
{
+ var username = User.GetUsername();
+ var filename = Path.GetFileNameWithoutExtension(downloadName);
try
{
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
- MessageFactory.DownloadProgressEvent(User.GetUsername(),
- Path.GetFileNameWithoutExtension(downloadName), 0F, "started"));
+ MessageFactory.DownloadProgressEvent(username,
+ filename, $"Downloading {filename}", 0F, "started"));
if (files.Count == 1)
{
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
- MessageFactory.DownloadProgressEvent(User.GetUsername(),
- Path.GetFileNameWithoutExtension(downloadName), 1F, "ended"));
+ MessageFactory.DownloadProgressEvent(username,
+ filename, $"Downloading {filename}",1F, "ended"));
return GetFirstFileDownload(files);
}
- var filePath = _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), tempFolder);
+ var filePath = _archiveService.CreateZipFromFoldersForDownload(files.Select(c => c.FilePath).ToList(), tempFolder, ProgressCallback);
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
- MessageFactory.DownloadProgressEvent(User.GetUsername(),
- Path.GetFileNameWithoutExtension(downloadName), 1F, "ended"));
+ MessageFactory.DownloadProgressEvent(username,
+ filename, "Download Complete", 1F, "ended"));
return PhysicalFile(filePath, DefaultContentType, Uri.EscapeDataString(downloadName), true);
+
+ async Task ProgressCallback(Tuple progressInfo)
+ {
+ await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
+ MessageFactory.DownloadProgressEvent(username, filename, $"Extracting {Path.GetFileNameWithoutExtension(progressInfo.Item1)}",
+ Math.Clamp(progressInfo.Item2, 0F, 1F)));
+ }
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception when trying to download files");
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.DownloadProgressEvent(User.GetUsername(),
- Path.GetFileNameWithoutExtension(downloadName), 1F, "ended"));
+ filename, "Download Complete", 1F, "ended"));
throw;
}
}
@@ -216,15 +225,15 @@ public class DownloadController : BaseApiController
var filename = $"{series!.Name} - Bookmarks.zip";
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
- MessageFactory.DownloadProgressEvent(username, Path.GetFileNameWithoutExtension(filename), 0F));
+ MessageFactory.DownloadProgressEvent(username, Path.GetFileNameWithoutExtension(filename), $"Downloading {filename}",0F));
var seriesIds = string.Join("_", downloadBookmarkDto.Bookmarks.Select(b => b.SeriesId).Distinct());
var filePath = _archiveService.CreateZipForDownload(files,
$"download_{userId}_{seriesIds}_bookmarks");
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
- MessageFactory.DownloadProgressEvent(username, Path.GetFileNameWithoutExtension(filename), 1F));
+ MessageFactory.DownloadProgressEvent(username, Path.GetFileNameWithoutExtension(filename), $"Downloading {filename}", 1F));
- return PhysicalFile(filePath, DefaultContentType, System.Web.HttpUtility.UrlEncode(filename), true);
+ return PhysicalFile(filePath, DefaultContentType, Uri.EscapeDataString(filename), true);
}
}
diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs
index d043188d8..b4b86dccf 100644
--- a/API/Controllers/LibraryController.cs
+++ b/API/Controllers/LibraryController.cs
@@ -112,6 +112,13 @@ public class LibraryController : BaseApiController
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library"));
_logger.LogInformation("Created a new library: {LibraryName}", library.Name);
+ // Restart Folder watching if on
+ var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
+ if (settings.EnableFolderWatching)
+ {
+ await _libraryWatcher.RestartWatching();
+ }
+
// Assign all the necessary users with this library side nav
var userIds = admins.Select(u => u.Id).Append(User.GetUserId()).ToList();
var userNeedingNewLibrary = (await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.SideNavStreams))
diff --git a/API/Controllers/LicenseController.cs b/API/Controllers/LicenseController.cs
index 08a7789ad..30c85d22c 100644
--- a/API/Controllers/LicenseController.cs
+++ b/API/Controllers/LicenseController.cs
@@ -2,7 +2,6 @@
using System.Threading.Tasks;
using API.Constants;
using API.Data;
-using API.DTOs.Account;
using API.DTOs.License;
using API.Entities.Enums;
using API.Extensions;
@@ -20,7 +19,8 @@ public class LicenseController(
IUnitOfWork unitOfWork,
ILogger logger,
ILicenseService licenseService,
- ILocalizationService localizationService)
+ ILocalizationService localizationService,
+ ITaskScheduler taskScheduler)
: BaseApiController
{
///
@@ -31,7 +31,9 @@ public class LicenseController(
[ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)]
public async Task> HasValidLicense(bool forceCheck = false)
{
- return Ok(await licenseService.HasActiveLicense(forceCheck));
+ var result = await licenseService.HasActiveLicense(forceCheck);
+ await taskScheduler.ScheduleKavitaPlusTasks();
+ return Ok(result);
}
///
@@ -57,6 +59,7 @@ public class LicenseController(
setting.Value = null;
unitOfWork.SettingsRepository.Update(setting);
await unitOfWork.CommitAsync();
+ await taskScheduler.ScheduleKavitaPlusTasks();
return Ok();
}
@@ -65,7 +68,11 @@ public class LicenseController(
public async Task ResetLicense(UpdateLicenseDto dto)
{
logger.LogInformation("Resetting license on file for Server");
- if (await licenseService.ResetLicense(dto.License, dto.Email)) return Ok();
+ if (await licenseService.ResetLicense(dto.License, dto.Email))
+ {
+ await taskScheduler.ScheduleKavitaPlusTasks();
+ return Ok();
+ }
return BadRequest(localizationService.Translate(User.GetUserId(), "unable-to-reset-k+"));
}
@@ -82,6 +89,7 @@ public class LicenseController(
try
{
await licenseService.AddLicense(dto.License.Trim(), dto.Email.Trim(), dto.DiscordId);
+ await taskScheduler.ScheduleKavitaPlusTasks();
}
catch (Exception ex)
{
diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs
index b3dbb8a01..24dedef47 100644
--- a/API/Controllers/MetadataController.cs
+++ b/API/Controllers/MetadataController.cs
@@ -8,9 +8,12 @@ using API.Data;
using API.DTOs;
using API.DTOs.Filtering;
using API.DTOs.Metadata;
+using API.DTOs.Recommendation;
+using API.DTOs.SeriesDetail;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
+using API.Services.Plus;
using Kavita.Common.Extensions;
using Microsoft.AspNetCore.Mvc;
@@ -18,16 +21,11 @@ namespace API.Controllers;
#nullable enable
-public class MetadataController : BaseApiController
+public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService localizationService,
+ IExternalMetadataService metadataService)
+ : BaseApiController
{
- private readonly IUnitOfWork _unitOfWork;
- private readonly ILocalizationService _localizationService;
-
- public MetadataController(IUnitOfWork unitOfWork, ILocalizationService localizationService)
- {
- _unitOfWork = unitOfWork;
- _localizationService = localizationService;
- }
+ public const string CacheKey = "kavitaPlusSeriesDetail_";
///
/// Fetches genres from the instance
@@ -39,12 +37,12 @@ public class MetadataController : BaseApiController
public async Task>> GetAllGenres(string? libraryIds)
{
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
- if (ids != null && ids.Count > 0)
+ if (ids is {Count: > 0})
{
- return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids, User.GetUserId()));
+ return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids, User.GetUserId()));
}
- return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosAsync(User.GetUserId()));
+ return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosAsync(User.GetUserId()));
}
///
@@ -53,12 +51,12 @@ public class MetadataController : BaseApiController
/// role
///
[HttpGet("people-by-role")]
- [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"role"})]
+ [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["role"])]
public async Task>> GetAllPeople(PersonRole? role)
{
return role.HasValue ?
- Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosByRoleAsync(User.GetUserId(), role!.Value)) :
- Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
+ Ok(await unitOfWork.PersonRepository.GetAllPersonDtosByRoleAsync(User.GetUserId(), role.Value)) :
+ Ok(await unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
}
///
@@ -67,15 +65,15 @@ public class MetadataController : BaseApiController
/// String separated libraryIds or null for all people
///
[HttpGet("people")]
- [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
+ [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["libraryIds"])]
public async Task>> GetAllPeople(string? libraryIds)
{
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
- if (ids != null && ids.Count > 0)
+ if (ids is {Count: > 0})
{
- return Ok(await _unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids, User.GetUserId()));
+ return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids, User.GetUserId()));
}
- return Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
+ return Ok(await unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
}
///
@@ -84,15 +82,15 @@ public class MetadataController : BaseApiController
/// String separated libraryIds or null for all tags
///
[HttpGet("tags")]
- [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
+ [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["libraryIds"])]
public async Task>> GetAllTags(string? libraryIds)
{
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
- if (ids != null && ids.Count > 0)
+ if (ids is {Count: > 0})
{
- return Ok(await _unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids, User.GetUserId()));
+ return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids, User.GetUserId()));
}
- return Ok(await _unitOfWork.TagRepository.GetAllTagDtosAsync(User.GetUserId()));
+ return Ok(await unitOfWork.TagRepository.GetAllTagDtosAsync(User.GetUserId()));
}
///
@@ -101,14 +99,14 @@ public class MetadataController : BaseApiController
/// String separated libraryIds or null for all ratings
/// This API is cached for 1 hour, varying by libraryIds
///
- [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new [] {"libraryIds"})]
+ [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = ["libraryIds"])]
[HttpGet("age-ratings")]
public async Task>> GetAllAgeRatings(string? libraryIds)
{
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
- if (ids != null && ids.Count > 0)
+ if (ids is {Count: > 0})
{
- return Ok(await _unitOfWork.LibraryRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids));
+ return Ok(await unitOfWork.LibraryRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids));
}
return Ok(Enum.GetValues().Select(t => new AgeRatingDto()
@@ -131,7 +129,7 @@ public class MetadataController : BaseApiController
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids is {Count: > 0})
{
- return Ok(_unitOfWork.LibraryRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids));
+ return Ok(unitOfWork.LibraryRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids));
}
return Ok(Enum.GetValues().Select(t => new PublicationStatusDto()
@@ -152,10 +150,13 @@ public class MetadataController : BaseApiController
public async Task>> GetAllLanguages(string? libraryIds)
{
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
- return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids));
+ return Ok(await unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids));
}
-
+ ///
+ /// Returns all languages Kavita can accept
+ ///
+ ///
[HttpGet("all-languages")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)]
public IEnumerable GetAllValidLanguages()
@@ -177,9 +178,68 @@ public class MetadataController : BaseApiController
[HttpGet("chapter-summary")]
public async Task> GetChapterSummary(int chapterId)
{
- if (chapterId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
- var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
- if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
+ // TODO: This doesn't seem used anywhere
+ if (chapterId <= 0) return BadRequest(await localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
+ var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
+ if (chapter == null) return BadRequest(await localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
return Ok(chapter.Summary);
}
+
+ ///
+ /// If this Series is on Kavita+ Blacklist, removes it. If already cached, invalidates it.
+ /// This then attempts to refresh data from Kavita+ for this series.
+ ///
+ ///
+ ///
+ [HttpPost("force-refresh")]
+ public async Task ForceRefresh(int seriesId)
+ {
+ await metadataService.ForceKavitaPlusRefresh(seriesId);
+ return Ok();
+ }
+
+ ///
+ /// Fetches the details needed from Kavita+ for Series Detail page
+ ///
+ /// This will hit upstream K+ if the data in local db is 2 weeks old
+ /// Series Id
+ /// Library Type
+ ///
+ [HttpGet("series-detail-plus")]
+ public async Task> GetKavitaPlusSeriesDetailData(int seriesId, LibraryType libraryType)
+ {
+ var userReviews = (await unitOfWork.UserRepository.GetUserRatingDtosForSeriesAsync(seriesId, User.GetUserId()))
+ .Where(r => !string.IsNullOrEmpty(r.Body))
+ .OrderByDescending(review => review.Username.Equals(User.GetUsername()) ? 1 : 0)
+ .ToList();
+
+ var ret = await metadataService.GetSeriesDetailPlus(seriesId, libraryType);
+
+ await PrepareSeriesDetail(userReviews, ret);
+ return Ok(ret);
+ }
+
+ private async Task PrepareSeriesDetail(List userReviews, SeriesDetailPlusDto ret)
+ {
+ var isAdmin = User.IsInRole(PolicyConstants.AdminRole);
+ var user = await unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId())!;
+
+ userReviews.AddRange(ReviewService.SelectSpectrumOfReviews(ret.Reviews.ToList()));
+ ret.Reviews = userReviews;
+
+ if (!isAdmin && ret.Recommendations != null && user != null)
+ {
+ // Re-obtain owned series and take into account age restriction
+ ret.Recommendations.OwnedSeries =
+ await unitOfWork.SeriesRepository.GetSeriesDtoByIdsAsync(
+ ret.Recommendations.OwnedSeries.Select(s => s.Id), user);
+ ret.Recommendations.ExternalSeries = new List();
+ }
+
+ if (ret.Recommendations != null && user != null)
+ {
+ ret.Recommendations.OwnedSeries ??= new List();
+ await unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, ret.Recommendations.OwnedSeries);
+ }
+ }
}
diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs
index a29daa165..2482ef714 100644
--- a/API/Controllers/OPDSController.cs
+++ b/API/Controllers/OPDSController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
@@ -301,7 +301,7 @@ public class OpdsController : BaseApiController
///
[HttpGet("{apiKey}/smart-filter/{filterId}")]
[Produces("application/xml")]
- public async Task GetSmartFilter(string apiKey, int filterId)
+ public async Task GetSmartFilter(string apiKey, int filterId, [FromQuery] int pageNumber = 0)
{
var userId = await GetUser(apiKey);
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
@@ -315,7 +315,7 @@ public class OpdsController : BaseApiController
SetFeedId(feed, "smartFilter-" + filter.Id);
var decodedFilter = SmartFilterHelper.Decode(filter.Filter);
- var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, UserParams.Default,
+ var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, GetUserParams(pageNumber),
decodedFilter);
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id));
@@ -1109,7 +1109,7 @@ public class OpdsController : BaseApiController
title += $" - {volume.Name}";
}
}
- else if (volume.Number != 0)
+ else if (volume.MinNumber != 0)
{
title = $"{series.Name} - Volume {volume.Name} - {await _seriesService.FormatChapterTitle(userId, chapter, libraryType)}";
}
@@ -1250,7 +1250,7 @@ public class OpdsController : BaseApiController
if (progress != null)
{
link.LastRead = progress.PageNum;
- link.LastReadDate = progress.LastModifiedUtc;
+ link.LastReadDate = progress.LastModifiedUtc.ToString("s"); // Adhere to ISO 8601
}
link.IsPageStream = true;
return link;
diff --git a/API/Controllers/PanelsController.cs b/API/Controllers/PanelsController.cs
index 2008b0c8d..c53b68f86 100644
--- a/API/Controllers/PanelsController.cs
+++ b/API/Controllers/PanelsController.cs
@@ -57,7 +57,7 @@ public class PanelsController : BaseApiController
PageNum = 0,
ChapterId = chapterId,
VolumeId = 0,
- SeriesId = 0
+ SeriesId = 0,
});
return Ok(progress);
}
diff --git a/API/Controllers/PluginController.cs b/API/Controllers/PluginController.cs
index ff33cf8e1..ce2e4eced 100644
--- a/API/Controllers/PluginController.cs
+++ b/API/Controllers/PluginController.cs
@@ -14,19 +14,9 @@ namespace API.Controllers;
#nullable enable
-public class PluginController : BaseApiController
+public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService, ILogger logger)
+ : BaseApiController
{
- private readonly IUnitOfWork _unitOfWork;
- private readonly ITokenService _tokenService;
- private readonly ILogger _logger;
-
- public PluginController(IUnitOfWork unitOfWork, ITokenService tokenService, ILogger logger)
- {
- _unitOfWork = unitOfWork;
- _tokenService = tokenService;
- _logger = logger;
- }
-
///
/// Authenticate with the Server given an apiKey. This will log you in by returning the user object and the JWT token.
///
@@ -42,11 +32,11 @@ public class PluginController : BaseApiController
// NOTE: In order to log information about plugins, we need some Plugin Description information for each request
// Should log into access table so we can tell the user
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
- var userAgent = HttpContext.Request.Headers["User-Agent"];
- var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
+ var userAgent = HttpContext.Request.Headers.UserAgent;
+ var userId = await unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId <= 0)
{
- _logger.LogInformation("A Plugin ({PluginName}) tried to authenticate with an apiKey that doesn't match. Information {@Information}", pluginName.Replace(Environment.NewLine, string.Empty), new
+ logger.LogInformation("A Plugin ({PluginName}) tried to authenticate with an apiKey that doesn't match. Information {@Information}", pluginName.Replace(Environment.NewLine, string.Empty), new
{
IpAddress = ipAddress,
UserAgent = userAgent,
@@ -54,15 +44,15 @@ public class PluginController : BaseApiController
});
throw new KavitaUnauthenticatedUserException();
}
- var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
- _logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId);
+ var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId);
+ logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId);
return new UserDto
{
Username = user.UserName!,
- Token = await _tokenService.CreateToken(user),
- RefreshToken = await _tokenService.CreateRefreshToken(user),
+ Token = await tokenService.CreateToken(user),
+ RefreshToken = await tokenService.CreateRefreshToken(user),
ApiKey = user.ApiKey,
- KavitaVersion = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value
+ KavitaVersion = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value
};
}
@@ -76,8 +66,8 @@ public class PluginController : BaseApiController
[HttpGet("version")]
public async Task> GetVersion([Required] string apiKey)
{
- var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
+ var userId = await unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId <= 0) throw new KavitaUnauthenticatedUserException();
- return Ok((await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value);
+ return Ok((await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value);
}
}
diff --git a/API/Controllers/RatingController.cs b/API/Controllers/RatingController.cs
index e82cb1fbd..a40b6680b 100644
--- a/API/Controllers/RatingController.cs
+++ b/API/Controllers/RatingController.cs
@@ -20,50 +20,12 @@ namespace API.Controllers;
///
public class RatingController : BaseApiController
{
- private readonly ILicenseService _licenseService;
- private readonly IRatingService _ratingService;
- private readonly ILogger _logger;
private readonly IUnitOfWork _unitOfWork;
- private readonly IEasyCachingProvider _cacheProvider;
- public const string CacheKey = "rating_";
- public RatingController(ILicenseService licenseService, IRatingService ratingService,
- ILogger logger, IEasyCachingProviderFactory cachingProviderFactory, IUnitOfWork unitOfWork)
+ public RatingController(IUnitOfWork unitOfWork)
{
- _licenseService = licenseService;
- _ratingService = ratingService;
- _logger = logger;
_unitOfWork = unitOfWork;
- _cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings);
- }
-
- ///
- /// Get the external ratings for a given series
- ///
- ///
- ///
- [HttpGet]
- [ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})]
- public async Task>> GetRating(int seriesId)
- {
-
- if (!await _licenseService.HasActiveLicense())
- {
- return Ok(Enumerable.Empty());
- }
-
- var cacheKey = CacheKey + seriesId;
- var results = await _cacheProvider.GetAsync>(cacheKey);
- if (results.HasValue)
- {
- return Ok(results.Value);
- }
-
- var ratings = await _ratingService.GetRatings(seriesId);
- await _cacheProvider.SetAsync(cacheKey, ratings, TimeSpan.FromHours(24));
- _logger.LogDebug("Caching external rating for {Key}", cacheKey);
- return Ok(ratings);
}
[HttpGet("overall")]
diff --git a/API/Controllers/RecommendedController.cs b/API/Controllers/RecommendedController.cs
index 979584032..259b84fd8 100644
--- a/API/Controllers/RecommendedController.cs
+++ b/API/Controllers/RecommendedController.cs
@@ -1,19 +1,9 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
-using API.Constants;
+using System.Threading.Tasks;
using API.Data;
using API.DTOs;
-using API.DTOs.Recommendation;
using API.Extensions;
using API.Helpers;
-using API.Services;
-using API.Services.Plus;
-using EasyCaching.Core;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.Extensions.Caching.Memory;
-using Newtonsoft.Json;
namespace API.Controllers;
@@ -22,56 +12,14 @@ namespace API.Controllers;
public class RecommendedController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
- private readonly IRecommendationService _recommendationService;
- private readonly ILicenseService _licenseService;
- private readonly ILocalizationService _localizationService;
- private readonly IEasyCachingProvider _cacheProvider;
+
public const string CacheKey = "recommendation_";
- public RecommendedController(IUnitOfWork unitOfWork, IRecommendationService recommendationService,
- ILicenseService licenseService, IEasyCachingProviderFactory cachingProviderFactory,
- ILocalizationService localizationService)
+ public RecommendedController(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
- _recommendationService = recommendationService;
- _licenseService = licenseService;
- _localizationService = localizationService;
- _cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRecommendations);
}
- ///
- /// For Kavita+ users, this will return recommendations on the server.
- ///
- ///
- ///
- [HttpGet("recommendations")]
- [ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})]
- public async Task> GetRecommendations(int seriesId)
- {
- var userId = User.GetUserId();
- if (!await _licenseService.HasActiveLicense())
- {
- return Ok(new RecommendationDto());
- }
-
- if (!await _unitOfWork.UserRepository.HasAccessToSeries(userId, seriesId))
- {
- return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-restricted"));
- }
-
- var cacheKey = $"{CacheKey}-{seriesId}-{userId}";
- var results = await _cacheProvider.GetAsync(cacheKey);
- if (results.HasValue)
- {
- return Ok(results.Value);
- }
-
- var ret = await _recommendationService.GetRecommendationsForSeries(userId, seriesId);
- await _cacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromHours(10));
- return Ok(ret);
- }
-
-
///
/// Quick Reads are series that should be readable in less than 10 in total and are not Ongoing in release.
///
@@ -79,7 +27,7 @@ public class RecommendedController : BaseApiController
/// Pagination
///
[HttpGet("quick-reads")]
- public async Task>> GetQuickReads(int libraryId, [FromQuery] UserParams userParams)
+ public async Task>> GetQuickReads(int libraryId, [FromQuery] UserParams? userParams)
{
userParams ??= UserParams.Default;
var series = await _unitOfWork.SeriesRepository.GetQuickReads(User.GetUserId(), libraryId, userParams);
@@ -95,7 +43,7 @@ public class RecommendedController : BaseApiController
///
///
[HttpGet("quick-catchup-reads")]
- public async Task>> GetQuickCatchupReads(int libraryId, [FromQuery] UserParams userParams)
+ public async Task>> GetQuickCatchupReads(int libraryId, [FromQuery] UserParams? userParams)
{
userParams ??= UserParams.Default;
var series = await _unitOfWork.SeriesRepository.GetQuickCatchupReads(User.GetUserId(), libraryId, userParams);
@@ -111,7 +59,7 @@ public class RecommendedController : BaseApiController
/// Pagination
///
[HttpGet("highly-rated")]
- public async Task>> GetHighlyRated(int libraryId, [FromQuery] UserParams userParams)
+ public async Task>> GetHighlyRated(int libraryId, [FromQuery] UserParams? userParams)
{
var userId = User.GetUserId();
userParams ??= UserParams.Default;
@@ -129,7 +77,7 @@ public class RecommendedController : BaseApiController
/// Pagination
///
[HttpGet("more-in")]
- public async Task>> GetMoreIn(int libraryId, int genreId, [FromQuery] UserParams userParams)
+ public async Task>> GetMoreIn(int libraryId, int genreId, [FromQuery] UserParams? userParams)
{
var userId = User.GetUserId();
@@ -148,7 +96,7 @@ public class RecommendedController : BaseApiController
/// Pagination
///
[HttpGet("rediscover")]
- public async Task>> GetRediscover(int libraryId, [FromQuery] UserParams userParams)
+ public async Task>> GetRediscover(int libraryId, [FromQuery] UserParams? userParams)
{
userParams ??= UserParams.Default;
var series = await _unitOfWork.SeriesRepository.GetRediscover(User.GetUserId(), libraryId, userParams);
diff --git a/API/Controllers/ReviewController.cs b/API/Controllers/ReviewController.cs
index 63ff20407..ae8ce02ee 100644
--- a/API/Controllers/ReviewController.cs
+++ b/API/Controllers/ReviewController.cs
@@ -1,20 +1,14 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
+using System.Linq;
using System.Threading.Tasks;
-using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs.SeriesDetail;
using API.Extensions;
using API.Helpers.Builders;
-using API.Services;
using API.Services.Plus;
using AutoMapper;
-using EasyCaching.Core;
using Hangfire;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.Extensions.Logging;
namespace API.Controllers;
@@ -22,109 +16,19 @@ namespace API.Controllers;
public class ReviewController : BaseApiController
{
- private readonly ILogger _logger;
private readonly IUnitOfWork _unitOfWork;
- private readonly ILicenseService _licenseService;
private readonly IMapper _mapper;
- private readonly IReviewService _reviewService;
private readonly IScrobblingService _scrobblingService;
- private readonly IEasyCachingProvider _cacheProvider;
- public const string CacheKey = "review_";
- public ReviewController(ILogger logger, IUnitOfWork unitOfWork, ILicenseService licenseService,
- IMapper mapper, IReviewService reviewService, IScrobblingService scrobblingService,
- IEasyCachingProviderFactory cachingProviderFactory)
+ public ReviewController(IUnitOfWork unitOfWork,
+ IMapper mapper, IScrobblingService scrobblingService)
{
- _logger = logger;
_unitOfWork = unitOfWork;
- _licenseService = licenseService;
_mapper = mapper;
- _reviewService = reviewService;
_scrobblingService = scrobblingService;
-
- _cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusReviews);
}
- ///
- /// Fetches reviews from the server for a given series
- ///
- ///
- [HttpGet]
- [ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})]
- public async Task>> GetReviews(int seriesId)
- {
- var userId = User.GetUserId();
- var username = User.GetUsername();
- var userRatings = (await _unitOfWork.UserRepository.GetUserRatingDtosForSeriesAsync(seriesId, userId))
- .Where(r => !string.IsNullOrEmpty(r.Body))
- .OrderByDescending(review => review.Username.Equals(username) ? 1 : 0)
- .ToList();
- if (!await _licenseService.HasActiveLicense())
- {
- return Ok(userRatings);
- }
-
- var cacheKey = CacheKey + seriesId;
- IList externalReviews;
-
- var result = await _cacheProvider.GetAsync>(cacheKey);
- if (result.HasValue)
- {
- externalReviews = result.Value.ToList();
- }
- else
- {
- var reviews = (await _reviewService.GetReviewsForSeries(userId, seriesId)).ToList();
- externalReviews = SelectSpectrumOfReviews(reviews);
-
- await _cacheProvider.SetAsync(cacheKey, externalReviews, TimeSpan.FromHours(10));
- _logger.LogDebug("Caching external reviews for {Key}", cacheKey);
- }
-
-
- // Fetch external reviews and splice them in
- userRatings.AddRange(externalReviews);
-
-
- return Ok(userRatings);
- }
-
- private static IList SelectSpectrumOfReviews(IList reviews)
- {
- IList externalReviews;
- var totalReviews = reviews.Count;
-
- if (totalReviews > 10)
- {
- var stepSize = Math.Max((totalReviews - 4) / 8, 1);
-
- var selectedReviews = new List()
- {
- reviews[0],
- reviews[1],
- };
- for (var i = 2; i < totalReviews - 2; i += stepSize)
- {
- selectedReviews.Add(reviews[i]);
-
- if (selectedReviews.Count >= 8)
- break;
- }
-
- selectedReviews.Add(reviews[totalReviews - 2]);
- selectedReviews.Add(reviews[totalReviews - 1]);
-
- externalReviews = selectedReviews;
- }
- else
- {
- externalReviews = reviews;
- }
-
- return externalReviews;
- }
-
///
/// Updates the review for a given series
///
@@ -157,4 +61,23 @@ public class ReviewController : BaseApiController
_scrobblingService.ScrobbleReviewUpdate(user.Id, dto.SeriesId, string.Empty, dto.Body));
return Ok(_mapper.Map(rating));
}
+
+ ///
+ /// Deletes the user's review for the given series
+ ///
+ ///
+ [HttpDelete]
+ public async Task DeleteReview(int seriesId)
+ {
+ var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Ratings);
+ if (user == null) return Unauthorized();
+
+ user.Ratings = user.Ratings.Where(r => r.SeriesId != seriesId).ToList();
+
+ _unitOfWork.UserRepository.Update(user);
+
+ await _unitOfWork.CommitAsync();
+
+ return Ok();
+ }
}
diff --git a/API/Controllers/ScrobblingController.cs b/API/Controllers/ScrobblingController.cs
index fc32c3a46..9707bbf61 100644
--- a/API/Controllers/ScrobblingController.cs
+++ b/API/Controllers/ScrobblingController.cs
@@ -39,22 +39,27 @@ public class ScrobblingController : BaseApiController
_localizationService = localizationService;
}
+ ///
+ /// Get the current user's AniList token
+ ///
+ ///
[HttpGet("anilist-token")]
- public async Task GetAniListToken()
+ public async Task> GetAniListToken()
{
- // Validate the license
-
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized();
return Ok(user.AniListAccessToken);
}
+ ///
+ /// Update the current user's AniList token
+ ///
+ ///
+ ///
[HttpPost("update-anilist-token")]
public async Task UpdateAniListToken(AniListUpdateDto dto)
{
- // Validate the license
-
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized();
@@ -71,6 +76,11 @@ public class ScrobblingController : BaseApiController
return Ok();
}
+ ///
+ /// Checks if the current Scrobbling token for the given Provider has expired for the current user
+ ///
+ ///
+ ///
[HttpGet("token-expired")]
public async Task> HasTokenExpired(ScrobbleProvider provider)
{
@@ -159,15 +169,20 @@ public class ScrobblingController : BaseApiController
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.ScrobbleHolds);
if (user == null) return Unauthorized();
if (user.ScrobbleHolds.Any(s => s.SeriesId == seriesId))
- return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
+ return Ok(await _localizationService.Translate(user.Id, "nothing-to-do"));
- var seriesHold = new ScrobbleHoldBuilder().WithSeriesId(seriesId).Build();
+ var seriesHold = new ScrobbleHoldBuilder()
+ .WithSeriesId(seriesId)
+ .Build();
user.ScrobbleHolds.Add(seriesHold);
_unitOfWork.UserRepository.Update(user);
try
{
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
+
+ // When a hold is placed on a series, clear any pre-existing Scrobble Events
+ await _scrobblingService.ClearEventsForSeries(user.Id, seriesId);
return Ok();
}
catch (DbUpdateConcurrencyException ex)
diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs
index df74a54f7..f65ac0b38 100644
--- a/API/Controllers/SeriesController.cs
+++ b/API/Controllers/SeriesController.cs
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
-using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
@@ -39,16 +38,14 @@ public class SeriesController : BaseApiController
private readonly ILicenseService _licenseService;
private readonly ILocalizationService _localizationService;
private readonly IExternalMetadataService _externalMetadataService;
- private readonly IEasyCachingProvider _ratingCacheProvider;
- private readonly IEasyCachingProvider _reviewCacheProvider;
- private readonly IEasyCachingProvider _recommendationCacheProvider;
private readonly IEasyCachingProvider _externalSeriesCacheProvider;
- private const string CacheKey = "recommendation_";
+ private const string CacheKey = "externalSeriesData_";
public SeriesController(ILogger logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork,
ISeriesService seriesService, ILicenseService licenseService,
- IEasyCachingProviderFactory cachingProviderFactory, ILocalizationService localizationService, IExternalMetadataService externalMetadataService)
+ IEasyCachingProviderFactory cachingProviderFactory, ILocalizationService localizationService,
+ IExternalMetadataService externalMetadataService)
{
_logger = logger;
_taskScheduler = taskScheduler;
@@ -58,9 +55,6 @@ public class SeriesController : BaseApiController
_localizationService = localizationService;
_externalMetadataService = externalMetadataService;
- _ratingCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings);
- _reviewCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusReviews);
- _recommendationCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRecommendations);
_externalSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusExternalSeries);
}
@@ -128,6 +122,11 @@ public class SeriesController : BaseApiController
return Ok(series);
}
+ ///
+ /// Deletes a series from Kavita
+ ///
+ ///
+ /// If the series was deleted or not
[Authorize(Policy = "RequireAdminRole")]
[HttpDelete("{seriesId}")]
public async Task> DeleteSeries(int seriesId)
@@ -145,7 +144,7 @@ public class SeriesController : BaseApiController
var username = User.GetUsername();
_logger.LogInformation("Series {@SeriesId} is being deleted by {UserName}", dto.SeriesIds, username);
- if (await _seriesService.DeleteMultipleSeries(dto.SeriesIds)) return Ok();
+ if (await _seriesService.DeleteMultipleSeries(dto.SeriesIds)) return Ok(true);
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-series-delete"));
}
@@ -451,19 +450,6 @@ public class SeriesController : BaseApiController
if (!await _seriesService.UpdateSeriesMetadata(updateSeriesMetadataDto))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "update-metadata-fail"));
- if (await _licenseService.HasActiveLicense())
- {
- _logger.LogDebug("Clearing cache as series weblinks may have changed");
- await _reviewCacheProvider.RemoveAsync(ReviewController.CacheKey + updateSeriesMetadataDto.SeriesMetadata.SeriesId);
- await _ratingCacheProvider.RemoveAsync(RatingController.CacheKey + updateSeriesMetadataDto.SeriesMetadata.SeriesId);
-
- var allUsers = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Select(s => s.Id);
- foreach (var userId in allUsers)
- {
- await _recommendationCacheProvider.RemoveAsync(RecommendedController.CacheKey + $"{updateSeriesMetadataDto.SeriesMetadata.SeriesId}-{userId}");
- }
- }
-
return Ok(await _localizationService.Translate(User.GetUserId(), "series-updated"));
}
@@ -605,7 +591,7 @@ public class SeriesController : BaseApiController
await _externalSeriesCacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromMinutes(15));
return Ok(ret);
}
- catch (Exception ex)
+ catch (Exception)
{
return BadRequest("Unable to load External Series details");
}
diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs
index 46c7dc9f2..d4e1ed59b 100644
--- a/API/Controllers/ServerController.cs
+++ b/API/Controllers/ServerController.cs
@@ -38,18 +38,16 @@ public class ServerController : BaseApiController
private readonly IStatsService _statsService;
private readonly ICleanupService _cleanupService;
private readonly IScannerService _scannerService;
- private readonly IAccountService _accountService;
private readonly ITaskScheduler _taskScheduler;
private readonly IUnitOfWork _unitOfWork;
private readonly IEasyCachingProviderFactory _cachingProviderFactory;
private readonly ILocalizationService _localizationService;
- private readonly IEmailService _emailService;
public ServerController(ILogger logger,
- IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService,
- ICleanupService cleanupService, IScannerService scannerService, IAccountService accountService,
+ IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService,
+ IStatsService statsService, ICleanupService cleanupService, IScannerService scannerService,
ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, IEasyCachingProviderFactory cachingProviderFactory,
- ILocalizationService localizationService, IEmailService emailService)
+ ILocalizationService localizationService)
{
_logger = logger;
_backupService = backupService;
@@ -58,12 +56,10 @@ public class ServerController : BaseApiController
_statsService = statsService;
_cleanupService = cleanupService;
_scannerService = scannerService;
- _accountService = accountService;
_taskScheduler = taskScheduler;
_unitOfWork = unitOfWork;
_cachingProviderFactory = cachingProviderFactory;
_localizationService = localizationService;
- _emailService = emailService;
}
///
@@ -180,15 +176,35 @@ public class ServerController : BaseApiController
}
}
+ ///
+ /// Checks for updates and pushes an event to the UI
+ ///
+ /// Some users have websocket issues so this is not always reliable to alert the user
+ [HttpGet("check-for-updates")]
+ public async Task CheckForAnnouncements()
+ {
+ await _taskScheduler.CheckForUpdate();
+ return Ok();
+ }
+
///
/// Checks for updates, if no updates that are > current version installed, returns null
///
[HttpGet("check-update")]
- public async Task> CheckForUpdates()
+ public async Task> CheckForUpdates()
{
return Ok(await _versionUpdaterService.CheckForUpdate());
}
+ ///
+ /// Returns how many versions out of date this install is
+ ///
+ [HttpGet("check-out-of-date")]
+ public async Task> CheckHowOutOfDate()
+ {
+ return Ok(await _versionUpdaterService.GetNumberOfReleasesBehind());
+ }
+
///
/// Pull the Changelog for Kavita from Github and display
@@ -200,18 +216,6 @@ public class ServerController : BaseApiController
return Ok(await _versionUpdaterService.GetAllReleases());
}
- ///
- /// Is this server accessible to the outside net
- ///
- /// If the instance has the HostName set, this will return true whether or not it is accessible externally
- ///
- [HttpGet("accessible")]
- [AllowAnonymous]
- public async Task> IsServerAccessible()
- {
- return Ok(await _accountService.CheckIfAccessible(Request));
- }
-
///
/// Returns a list of reoccurring jobs. Scheduled ad-hoc jobs will not be returned.
///
@@ -260,35 +264,13 @@ public class ServerController : BaseApiController
///
///
[Authorize("RequireAdminRole")]
- [HttpPost("bust-review-and-rec-cache")]
+ [HttpPost("bust-kavitaplus-cache")]
public async Task BustReviewAndRecCache()
{
_logger.LogInformation("Busting Kavita+ Cache");
- var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusReviews);
- await provider.FlushAsync();
- provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRecommendations);
- await provider.FlushAsync();
- provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings);
- await provider.FlushAsync();
- provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusExternalSeries);
+ var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusExternalSeries);
await provider.FlushAsync();
return Ok();
}
- ///
- /// Returns the KavitaEmail version for non-default instances
- ///
- ///
- [Authorize("RequireAdminRole")]
- [HttpGet("email-version")]
- public async Task> GetEmailVersion()
- {
- var emailServiceUrl = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl))
- .Value;
-
- if (emailServiceUrl.Equals(EmailService.DefaultApiUrl)) return Ok(null);
-
- return Ok(await _emailService.GetVersion(emailServiceUrl));
- }
-
}
diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs
index 6277d709b..e0339309b 100644
--- a/API/Controllers/SettingsController.cs
+++ b/API/Controllers/SettingsController.cs
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using API.Data;
using API.DTOs.Email;
using API.DTOs.Settings;
+using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers.Converters;
@@ -13,7 +14,8 @@ using API.Logging;
using API.Services;
using API.Services.Tasks.Scanner;
using AutoMapper;
-using Flurl.Http;
+using Cronos;
+using Hangfire;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Extensions;
@@ -58,6 +60,10 @@ public class SettingsController : BaseApiController
return Ok(settingsDto.BaseUrl);
}
+ ///
+ /// Returns the server settings
+ ///
+ ///
[Authorize(Policy = "RequireAdminRole")]
[HttpGet]
public async Task> GetSettings()
@@ -119,38 +125,15 @@ public class SettingsController : BaseApiController
}
///
- /// Resets the email service url
+ /// Is the minimum information setup for Email to work
///
///
[Authorize(Policy = "RequireAdminRole")]
- [HttpPost("reset-email-url")]
- public async Task> ResetEmailServiceUrlSettings()
+ [HttpGet("is-email-setup")]
+ public async Task> IsEmailSetup()
{
- _logger.LogInformation("{UserName} is resetting Email Service Url Setting", User.GetUsername());
- var emailSetting = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl);
- emailSetting.Value = EmailService.DefaultApiUrl;
- _unitOfWork.SettingsRepository.Update(emailSetting);
-
- if (!await _unitOfWork.CommitAsync())
- {
- await _unitOfWork.RollbackAsync();
- }
-
- return Ok(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync());
- }
-
- ///
- /// Sends a test email from the Email Service. Will not send if email service is the Default Provider
- ///
- ///
- ///
- [Authorize(Policy = "RequireAdminRole")]
- [HttpPost("test-email-url")]
- public async Task> TestEmailServiceUrl(TestEmailDto dto)
- {
- var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId());
- var emailService = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value;
- return Ok(await _emailService.TestConnectivity(dto.Url, user!.Email, !emailService.Equals(EmailService.DefaultApiUrl)));
+ var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
+ return Ok(settings.IsEmailSetup());
}
@@ -170,7 +153,8 @@ public class SettingsController : BaseApiController
if (!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks") &&
!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks/"))
{
- bookmarkDirectory = _directoryService.FileSystem.Path.Join(updateSettingsDto.BookmarksDirectory, "bookmarks");
+ bookmarkDirectory =
+ _directoryService.FileSystem.Path.Join(updateSettingsDto.BookmarksDirectory, "bookmarks");
}
if (string.IsNullOrEmpty(updateSettingsDto.BookmarksDirectory))
@@ -180,42 +164,29 @@ public class SettingsController : BaseApiController
foreach (var setting in currentSettings)
{
- if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value)
- {
- setting.Value = updateSettingsDto.TaskBackup;
- _unitOfWork.SettingsRepository.Update(setting);
- }
+ UpdateSchedulingSettings(setting, updateSettingsDto);
- if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value)
- {
- setting.Value = updateSettingsDto.TaskScan;
- _unitOfWork.SettingsRepository.Update(setting);
- }
-
- if (setting.Key == ServerSettingKey.OnDeckProgressDays && updateSettingsDto.OnDeckProgressDays + string.Empty != setting.Value)
+ if (setting.Key == ServerSettingKey.OnDeckProgressDays &&
+ updateSettingsDto.OnDeckProgressDays + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.OnDeckProgressDays + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
- if (setting.Key == ServerSettingKey.OnDeckUpdateDays && updateSettingsDto.OnDeckUpdateDays + string.Empty != setting.Value)
+ if (setting.Key == ServerSettingKey.OnDeckUpdateDays &&
+ updateSettingsDto.OnDeckUpdateDays + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.OnDeckUpdateDays + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
- if (setting.Key == ServerSettingKey.CoverImageSize && updateSettingsDto.CoverImageSize + string.Empty != setting.Value)
+ if (setting.Key == ServerSettingKey.CoverImageSize &&
+ updateSettingsDto.CoverImageSize + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.CoverImageSize + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
- if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value)
- {
- setting.Value = updateSettingsDto.TaskScan;
- _unitOfWork.SettingsRepository.Update(setting);
- }
-
if (setting.Key == ServerSettingKey.Port && updateSettingsDto.Port + string.Empty != setting.Value)
{
if (OsInfo.IsDocker) continue;
@@ -225,7 +196,8 @@ public class SettingsController : BaseApiController
_unitOfWork.SettingsRepository.Update(setting);
}
- if (setting.Key == ServerSettingKey.CacheSize && updateSettingsDto.CacheSize + string.Empty != setting.Value)
+ if (setting.Key == ServerSettingKey.CacheSize &&
+ updateSettingsDto.CacheSize + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.CacheSize + string.Empty;
// CacheSize is managed in appSetting.json
@@ -233,14 +205,21 @@ public class SettingsController : BaseApiController
_unitOfWork.SettingsRepository.Update(setting);
}
+ UpdateEmailSettings(setting, updateSettingsDto);
+
+
+
if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value)
{
if (OsInfo.IsDocker) continue;
// Validate IP addresses
- foreach (var ipAddress in updateSettingsDto.IpAddresses.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
+ foreach (var ipAddress in updateSettingsDto.IpAddresses.Split(',',
+ StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
- if (!IPAddress.TryParse(ipAddress.Trim(), out _)) {
- return BadRequest(await _localizationService.Translate(User.GetUserId(), "ip-address-invalid", ipAddress));
+ if (!IPAddress.TryParse(ipAddress.Trim(), out _))
+ {
+ return BadRequest(await _localizationService.Translate(User.GetUserId(), "ip-address-invalid",
+ ipAddress));
}
}
@@ -263,20 +242,23 @@ public class SettingsController : BaseApiController
_unitOfWork.SettingsRepository.Update(setting);
}
- if (setting.Key == ServerSettingKey.LoggingLevel && updateSettingsDto.LoggingLevel + string.Empty != setting.Value)
+ if (setting.Key == ServerSettingKey.LoggingLevel &&
+ updateSettingsDto.LoggingLevel + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.LoggingLevel + string.Empty;
LogLevelOptions.SwitchLogLevel(updateSettingsDto.LoggingLevel);
_unitOfWork.SettingsRepository.Update(setting);
}
- if (setting.Key == ServerSettingKey.EnableOpds && updateSettingsDto.EnableOpds + string.Empty != setting.Value)
+ if (setting.Key == ServerSettingKey.EnableOpds &&
+ updateSettingsDto.EnableOpds + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.EnableOpds + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
- if (setting.Key == ServerSettingKey.EncodeMediaAs && updateSettingsDto.EncodeMediaAs + string.Empty != setting.Value)
+ if (setting.Key == ServerSettingKey.EncodeMediaAs &&
+ updateSettingsDto.EncodeMediaAs + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.EncodeMediaAs + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
@@ -289,23 +271,13 @@ public class SettingsController : BaseApiController
_unitOfWork.SettingsRepository.Update(setting);
}
- if (setting.Key == ServerSettingKey.EmailServiceUrl && updateSettingsDto.EmailServiceUrl + string.Empty != setting.Value)
- {
- setting.Value = string.IsNullOrEmpty(updateSettingsDto.EmailServiceUrl) ? EmailService.DefaultApiUrl : updateSettingsDto.EmailServiceUrl;
- setting.Value = UrlHelper.RemoveEndingSlash(setting.Value);
- FlurlHttp.ConfigureClient(setting.Value, cli =>
- cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
-
- _unitOfWork.SettingsRepository.Update(setting);
- }
-
-
if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value)
{
// Validate new directory can be used
if (!await _directoryService.CheckWriteAccess(bookmarkDirectory))
{
- return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-dir-permissions"));
+ return BadRequest(
+ await _localizationService.Translate(User.GetUserId(), "bookmark-dir-permissions"));
}
originalBookmarkDirectory = setting.Value;
@@ -316,7 +288,8 @@ public class SettingsController : BaseApiController
}
- if (setting.Key == ServerSettingKey.AllowStatCollection && updateSettingsDto.AllowStatCollection + string.Empty != setting.Value)
+ if (setting.Key == ServerSettingKey.AllowStatCollection &&
+ updateSettingsDto.AllowStatCollection + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.AllowStatCollection + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
@@ -330,27 +303,32 @@ public class SettingsController : BaseApiController
}
}
- if (setting.Key == ServerSettingKey.TotalBackups && updateSettingsDto.TotalBackups + string.Empty != setting.Value)
+ if (setting.Key == ServerSettingKey.TotalBackups &&
+ updateSettingsDto.TotalBackups + string.Empty != setting.Value)
{
if (updateSettingsDto.TotalBackups > 30 || updateSettingsDto.TotalBackups < 1)
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), "total-backups"));
}
+
setting.Value = updateSettingsDto.TotalBackups + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
- if (setting.Key == ServerSettingKey.TotalLogs && updateSettingsDto.TotalLogs + string.Empty != setting.Value)
+ if (setting.Key == ServerSettingKey.TotalLogs &&
+ updateSettingsDto.TotalLogs + string.Empty != setting.Value)
{
if (updateSettingsDto.TotalLogs > 30 || updateSettingsDto.TotalLogs < 1)
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), "total-logs"));
}
+
setting.Value = updateSettingsDto.TotalLogs + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
- if (setting.Key == ServerSettingKey.EnableFolderWatching && updateSettingsDto.EnableFolderWatching + string.Empty != setting.Value)
+ if (setting.Key == ServerSettingKey.EnableFolderWatching &&
+ updateSettingsDto.EnableFolderWatching + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.EnableFolderWatching + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
@@ -392,6 +370,97 @@ public class SettingsController : BaseApiController
return Ok(updateSettingsDto);
}
+ private void UpdateSchedulingSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)
+ {
+ if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value)
+ {
+ setting.Value = updateSettingsDto.TaskBackup;
+ _unitOfWork.SettingsRepository.Update(setting);
+ }
+
+ if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value)
+ {
+ setting.Value = updateSettingsDto.TaskScan;
+ _unitOfWork.SettingsRepository.Update(setting);
+ }
+
+ if (setting.Key == ServerSettingKey.TaskCleanup && updateSettingsDto.TaskCleanup != setting.Value)
+ {
+ setting.Value = updateSettingsDto.TaskCleanup;
+ _unitOfWork.SettingsRepository.Update(setting);
+ }
+ }
+
+ private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)
+ {
+ if (setting.Key == ServerSettingKey.EmailHost &&
+ updateSettingsDto.SmtpConfig.Host + string.Empty != setting.Value)
+ {
+ setting.Value = updateSettingsDto.SmtpConfig.Host + string.Empty;
+ _unitOfWork.SettingsRepository.Update(setting);
+ }
+
+ if (setting.Key == ServerSettingKey.EmailPort &&
+ updateSettingsDto.SmtpConfig.Port + string.Empty != setting.Value)
+ {
+ setting.Value = updateSettingsDto.SmtpConfig.Port + string.Empty;
+ _unitOfWork.SettingsRepository.Update(setting);
+ }
+
+ if (setting.Key == ServerSettingKey.EmailAuthPassword &&
+ updateSettingsDto.SmtpConfig.Password + string.Empty != setting.Value)
+ {
+ setting.Value = updateSettingsDto.SmtpConfig.Password + string.Empty;
+ _unitOfWork.SettingsRepository.Update(setting);
+ }
+
+ if (setting.Key == ServerSettingKey.EmailAuthUserName &&
+ updateSettingsDto.SmtpConfig.UserName + string.Empty != setting.Value)
+ {
+ setting.Value = updateSettingsDto.SmtpConfig.UserName + string.Empty;
+ _unitOfWork.SettingsRepository.Update(setting);
+ }
+
+ if (setting.Key == ServerSettingKey.EmailSenderAddress &&
+ updateSettingsDto.SmtpConfig.SenderAddress + string.Empty != setting.Value)
+ {
+ setting.Value = updateSettingsDto.SmtpConfig.SenderAddress + string.Empty;
+ _unitOfWork.SettingsRepository.Update(setting);
+ }
+
+ if (setting.Key == ServerSettingKey.EmailSenderDisplayName &&
+ updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty != setting.Value)
+ {
+ setting.Value = updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty;
+ _unitOfWork.SettingsRepository.Update(setting);
+ }
+
+ if (setting.Key == ServerSettingKey.EmailSizeLimit &&
+ updateSettingsDto.SmtpConfig.SizeLimit + string.Empty != setting.Value)
+ {
+ setting.Value = updateSettingsDto.SmtpConfig.SizeLimit + string.Empty;
+ _unitOfWork.SettingsRepository.Update(setting);
+ }
+
+ if (setting.Key == ServerSettingKey.EmailEnableSsl &&
+ updateSettingsDto.SmtpConfig.EnableSsl + string.Empty != setting.Value)
+ {
+ setting.Value = updateSettingsDto.SmtpConfig.EnableSsl + string.Empty;
+ _unitOfWork.SettingsRepository.Update(setting);
+ }
+
+ if (setting.Key == ServerSettingKey.EmailCustomizedTemplates &&
+ updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty != setting.Value)
+ {
+ setting.Value = updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty;
+ _unitOfWork.SettingsRepository.Update(setting);
+ }
+ }
+
+ ///
+ /// All values allowed for Task Scheduling APIs. A custom cron job is not included. Disabled is not applicable for Cleanup.
+ ///
+ ///
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("task-frequencies")]
public ActionResult> GetTaskFrequencies()
@@ -410,7 +479,7 @@ public class SettingsController : BaseApiController
[HttpGet("log-levels")]
public ActionResult> GetLogLevels()
{
- return Ok(new [] {"Trace", "Debug", "Information", "Warning", "Critical"});
+ return Ok(new[] {"Trace", "Debug", "Information", "Warning", "Critical"});
}
[HttpGet("opds-enabled")]
@@ -419,4 +488,28 @@ public class SettingsController : BaseApiController
var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
return Ok(settingsDto.EnableOpds);
}
+
+ ///
+ /// Is the cron expression valid for Kavita's scheduler
+ ///
+ ///
+ ///
+ [HttpGet("is-valid-cron")]
+ public ActionResult IsValidCron(string cronExpression)
+ {
+ // NOTE: This must match Hangfire's underlying cron system. Hangfire is unique
+ return Ok(CronHelper.IsValidCron(cronExpression));
+ }
+
+ ///
+ /// Sends a test email to see if email settings are hooked up correctly
+ ///
+ ///
+ [Authorize(Policy = "RequireAdminRole")]
+ [HttpPost("test-email-url")]
+ public async Task> TestEmailServiceUrl()
+ {
+ var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId());
+ return Ok(await _emailService.SendTestEmail(user!.Email));
+ }
}
diff --git a/API/Controllers/WantToReadController.cs b/API/Controllers/WantToReadController.cs
index 116215a36..b80607b56 100644
--- a/API/Controllers/WantToReadController.cs
+++ b/API/Controllers/WantToReadController.cs
@@ -7,6 +7,7 @@ using API.DTOs;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.WantToRead;
+using API.Entities;
using API.Extensions;
using API.Helpers;
using API.Services;
@@ -91,15 +92,15 @@ public class WantToReadController : BaseApiController
AppUserIncludes.WantToRead);
if (user == null) return Unauthorized();
- var existingIds = user.WantToRead.Select(s => s.Id).ToList();
- existingIds.AddRange(dto.SeriesIds);
+ var existingIds = user.WantToRead.Select(s => s.SeriesId).ToList();
+ var idsToAdd = dto.SeriesIds.Except(existingIds);
- var idsToAdd = existingIds.Distinct().ToList();
-
- var seriesToAdd = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(idsToAdd);
- foreach (var series in seriesToAdd)
+ foreach (var id in idsToAdd)
{
- user.WantToRead.Add(series);
+ user.WantToRead.Add(new AppUserWantToRead()
+ {
+ SeriesId = id
+ });
}
if (!_unitOfWork.HasChanges()) return Ok();
@@ -127,7 +128,9 @@ public class WantToReadController : BaseApiController
AppUserIncludes.WantToRead);
if (user == null) return Unauthorized();
- user.WantToRead = user.WantToRead.Where(s => !dto.SeriesIds.Contains(s.Id)).ToList();
+ user.WantToRead = user.WantToRead
+ .Where(s => !dto.SeriesIds.Contains(s.SeriesId))
+ .ToList();
if (!_unitOfWork.HasChanges()) return Ok();
if (await _unitOfWork.CommitAsync())
diff --git a/API/DTOs/Email/EmailTestResultDto.cs b/API/DTOs/Email/EmailTestResultDto.cs
index 6659e3a45..263e725c4 100644
--- a/API/DTOs/Email/EmailTestResultDto.cs
+++ b/API/DTOs/Email/EmailTestResultDto.cs
@@ -7,4 +7,5 @@ public class EmailTestResultDto
{
public bool Successful { get; set; }
public string ErrorMessage { get; set; } = default!;
+ public string EmailAddress { get; set; } = default!;
}
diff --git a/API/DTOs/Filtering/SortField.cs b/API/DTOs/Filtering/SortField.cs
index f30b617df..b072819f4 100644
--- a/API/DTOs/Filtering/SortField.cs
+++ b/API/DTOs/Filtering/SortField.cs
@@ -30,4 +30,8 @@ public enum SortField
/// Last time the user had any reading progress
///
ReadProgress = 7,
+ ///
+ /// Kavita+ Only - External Average Rating
+ ///
+ AverageRating = 8
}
diff --git a/API/DTOs/Filtering/v2/FilterField.cs b/API/DTOs/Filtering/v2/FilterField.cs
index 563c8e4a0..1efb385fa 100644
--- a/API/DTOs/Filtering/v2/FilterField.cs
+++ b/API/DTOs/Filtering/v2/FilterField.cs
@@ -45,5 +45,9 @@ public enum FilterField
/// Last time User Read
///
ReadingDate = 27,
+ ///
+ /// Average rating from Kavita+ - Not usable for non-licensed users
+ ///
+ AverageRating = 28
}
diff --git a/API/DTOs/OPDS/FeedLink.cs b/API/DTOs/OPDS/FeedLink.cs
index 2a9053f16..cff3b6736 100644
--- a/API/DTOs/OPDS/FeedLink.cs
+++ b/API/DTOs/OPDS/FeedLink.cs
@@ -39,7 +39,7 @@ public class FeedLink
///
/// Attribute MUST conform Atom's Date construct
[XmlAttribute("lastReadDate", Namespace = "http://vaemendis.net/opds-pse/ns")]
- public DateTime LastReadDate { get; set; }
+ public string LastReadDate { get; set; }
public bool ShouldSerializeLastReadDate()
{
diff --git a/API/DTOs/Scrobbling/MediaRecommendationDto.cs b/API/DTOs/Scrobbling/MediaRecommendationDto.cs
new file mode 100644
index 000000000..c83694b2b
--- /dev/null
+++ b/API/DTOs/Scrobbling/MediaRecommendationDto.cs
@@ -0,0 +1,17 @@
+using System.Collections.Generic;
+using API.Services.Plus;
+
+namespace API.DTOs.Scrobbling;
+
+public record MediaRecommendationDto
+{
+ public int Rating { get; set; }
+ public IEnumerable RecommendationNames { get; set; } = null!;
+ public string Name { get; set; }
+ public string CoverUrl { get; set; }
+ public string SiteUrl { get; set; }
+ public string? Summary { get; set; }
+ public int? AniListId { get; set; }
+ public long? MalId { get; set; }
+ public ScrobbleProvider Provider { get; set; }
+}
diff --git a/API/DTOs/Scrobbling/PlusSeriesDto.cs b/API/DTOs/Scrobbling/PlusSeriesDto.cs
new file mode 100644
index 000000000..552a86575
--- /dev/null
+++ b/API/DTOs/Scrobbling/PlusSeriesDto.cs
@@ -0,0 +1,21 @@
+namespace API.DTOs.Scrobbling;
+
+public record PlusSeriesDto
+{
+ public int? AniListId { get; set; }
+ public long? MalId { get; set; }
+ public string? GoogleBooksId { get; set; }
+ public string? MangaDexId { get; set; }
+ public string SeriesName { get; set; }
+ public string? AltSeriesName { get; set; }
+ public MediaFormat MediaFormat { get; set; }
+ ///
+ /// Optional but can help with matching
+ ///
+ public int? ChapterCount { get; set; }
+ ///
+ /// Optional but can help with matching
+ ///
+ public int? VolumeCount { get; set; }
+ public int? Year { get; set; }
+}
diff --git a/API/DTOs/Scrobbling/ScrobbleEventDto.cs b/API/DTOs/Scrobbling/ScrobbleEventDto.cs
index 25690da82..298e32180 100644
--- a/API/DTOs/Scrobbling/ScrobbleEventDto.cs
+++ b/API/DTOs/Scrobbling/ScrobbleEventDto.cs
@@ -8,11 +8,13 @@ public class ScrobbleEventDto
public int SeriesId { get; set; }
public int LibraryId { get; set; }
public bool IsProcessed { get; set; }
- public int? VolumeNumber { get; set; }
+ public float? VolumeNumber { get; set; }
public int? ChapterNumber { get; set; }
public DateTime LastModifiedUtc { get; set; }
public DateTime CreatedUtc { get; set; }
public float? Rating { get; set; }
public ScrobbleEventType ScrobbleEventType { get; set; }
+ public bool IsErrored { get; set; }
+ public string? ErrorDetails { get; set; }
}
diff --git a/API/DTOs/SeriesDetail/NextExpectedChapterDto.cs b/API/DTOs/SeriesDetail/NextExpectedChapterDto.cs
index df4cc1a07..0f1a8eb4b 100644
--- a/API/DTOs/SeriesDetail/NextExpectedChapterDto.cs
+++ b/API/DTOs/SeriesDetail/NextExpectedChapterDto.cs
@@ -5,7 +5,7 @@ namespace API.DTOs.SeriesDetail;
public class NextExpectedChapterDto
{
public float ChapterNumber { get; set; }
- public int VolumeNumber { get; set; }
+ public float VolumeNumber { get; set; }
///
/// Null if not applicable
///
diff --git a/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs b/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs
new file mode 100644
index 000000000..59ce47bf6
--- /dev/null
+++ b/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs
@@ -0,0 +1,15 @@
+using System.Collections.Generic;
+using API.DTOs.Recommendation;
+
+namespace API.DTOs.SeriesDetail;
+
+///
+/// All the data from Kavita+ for Series Detail
+///
+/// This is what the UI sees, not what the API sends back
+public class SeriesDetailPlusDto
+{
+ public RecommendationDto? Recommendations { get; set; }
+ public IEnumerable Reviews { get; set; }
+ public IEnumerable? Ratings { get; set; }
+}
diff --git a/API/DTOs/SeriesDetail/UserReviewDto.cs b/API/DTOs/SeriesDetail/UserReviewDto.cs
index c8b2f88c5..0e080d43f 100644
--- a/API/DTOs/SeriesDetail/UserReviewDto.cs
+++ b/API/DTOs/SeriesDetail/UserReviewDto.cs
@@ -14,27 +14,29 @@ public class UserReviewDto
///
/// This is not possible to set as a local user
public string? Tagline { get; set; }
-
///
/// The main review
///
public string Body { get; set; }
-
+ ///
+ /// The main body with just text, for review preview
+ ///
+ public string? BodyJustText { get; set; }
///
/// The series this is for
///
public int SeriesId { get; set; }
-
///
/// The library this series belongs in
///
public int LibraryId { get; set; }
-
///
/// The user who wrote this
///
public string Username { get; set; }
-
+ public int TotalVotes { get; set; }
+ public float Rating { get; set; }
+ public string? RawBody { get; set; }
///
/// How many upvotes this review has gotten
///
@@ -43,16 +45,11 @@ public class UserReviewDto
///
/// If External, the url of the review
///
- public string? ExternalUrl { get; set; }
+ public string? SiteUrl { get; set; }
///
/// Does this review come from an external Source
///
public bool IsExternal { get; set; }
- ///
- /// The main body with just text, for review preview
- ///
- public string? BodyJustText { get; set; }
-
///
/// If this review is External, which Provider did it come from
///
diff --git a/API/DTOs/Settings/SMTPConfigDto.cs b/API/DTOs/Settings/SMTPConfigDto.cs
new file mode 100644
index 000000000..07cc58cb8
--- /dev/null
+++ b/API/DTOs/Settings/SMTPConfigDto.cs
@@ -0,0 +1,20 @@
+namespace API.DTOs.Settings;
+
+public class SmtpConfigDto
+{
+ public string SenderAddress { get; set; } = string.Empty;
+ public string SenderDisplayName { get; set; } = string.Empty;
+ public string UserName { get; set; } = string.Empty;
+ public string Password { get; set; } = string.Empty;
+ public string Host { get; set; } = string.Empty;
+ public int Port { get; set; } = 0;
+ public bool EnableSsl { get; set; } = true;
+ ///
+ /// Limit in bytes for allowing files to be added as attachments. Defaults to 25MB
+ ///
+ public int SizeLimit { get; set; } = 26_214_400;
+ ///
+ /// Should Kavita use config/templates for Email templates or the default ones
+ ///
+ public bool CustomizedTemplates { get; set; } = false;
+}
diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs
index e405758bc..077ffbaac 100644
--- a/API/DTOs/Settings/ServerSettingDTO.cs
+++ b/API/DTOs/Settings/ServerSettingDTO.cs
@@ -8,11 +8,12 @@ public class ServerSettingDto
public string CacheDirectory { get; set; } = default!;
public string TaskScan { get; set; } = default!;
+ public string TaskBackup { get; set; } = default!;
+ public string TaskCleanup { get; set; } = default!;
///
/// Logging level for server. Managed in appsettings.json.
///
public string LoggingLevel { get; set; } = default!;
- public string TaskBackup { get; set; } = default!;
///
/// Port the server listens on. Managed in appsettings.json.
///
@@ -38,11 +39,6 @@ public class ServerSettingDto
///
/// If null or empty string, will default back to default install setting aka
public string BookmarksDirectory { get; set; } = default!;
- ///
- /// Email service to use for the invite user flow, forgot password, etc.
- ///
- /// If null or empty string, will default back to default install setting aka
- public string EmailServiceUrl { get; set; } = default!;
public string InstallVersion { get; set; } = default!;
///
/// Represents a unique Id to this Kavita installation. Only used in Stats to identify unique installs.
@@ -88,4 +84,29 @@ public class ServerSettingDto
/// How large the cover images should be
///
public CoverImageSize CoverImageSize { get; set; }
+ ///
+ /// SMTP Configuration
+ ///
+ public SmtpConfigDto SmtpConfig { get; set; }
+
+ ///
+ /// Are at least some basics filled in
+ ///
+ ///
+ public bool IsEmailSetup()
+ {
+ return !string.IsNullOrEmpty(SmtpConfig.Host)
+ && !string.IsNullOrEmpty(SmtpConfig.UserName)
+ && !string.IsNullOrEmpty(HostName);
+ }
+
+ ///
+ /// Are at least some basics filled in, but not hostname as not required for Send to Device
+ ///
+ ///
+ public bool IsEmailSetupForSendToDevice()
+ {
+ return !string.IsNullOrEmpty(SmtpConfig.Host)
+ && !string.IsNullOrEmpty(SmtpConfig.UserName);
+ }
}
diff --git a/API/DTOs/Update/UpdateNotificationDto.cs b/API/DTOs/Update/UpdateNotificationDto.cs
index 95719bb27..63e3e8088 100644
--- a/API/DTOs/Update/UpdateNotificationDto.cs
+++ b/API/DTOs/Update/UpdateNotificationDto.cs
@@ -38,4 +38,16 @@ public class UpdateNotificationDto
/// Date of the publish
///
public required string PublishDate { get; init; }
+ ///
+ /// Is the server on a nightly within this release
+ ///
+ public bool IsOnNightlyInRelease { get; set; }
+ ///
+ /// Is the server on an older version
+ ///
+ public bool IsReleaseNewer { get; set; }
+ ///
+ /// Is the server on this version
+ ///
+ public bool IsReleaseEqual { get; set; }
}
diff --git a/API/DTOs/VolumeDto.cs b/API/DTOs/VolumeDto.cs
index 8b1d49a7a..4820f4d95 100644
--- a/API/DTOs/VolumeDto.cs
+++ b/API/DTOs/VolumeDto.cs
@@ -9,11 +9,17 @@ namespace API.DTOs;
public class VolumeDto : IHasReadTimeEstimate
{
public int Id { get; set; }
- ///
- public int Number { get; set; }
-
+ ///
+ public float MinNumber { get; set; }
+ ///
+ public float MaxNumber { get; set; }
///
public string Name { get; set; } = default!;
+ ///
+ /// This will map to MinNumber. Number was removed in v0.7.13.8/v0.7.14
+ ///
+ [Obsolete("Use MinNumber")]
+ public float Number { get; set; }
public int Pages { get; set; }
public int PagesRead { get; set; }
public DateTime LastModifiedUtc { get; set; }
diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs
index c20e84d2a..6d37d95bc 100644
--- a/API/Data/DataContext.cs
+++ b/API/Data/DataContext.cs
@@ -58,6 +58,12 @@ public sealed class DataContext : IdentityDbContext AppUserDashboardStream { get; set; } = null!;
public DbSet AppUserSideNavStream { get; set; } = null!;
public DbSet AppUserExternalSource { get; set; } = null!;
+ public DbSet ExternalReview { get; set; } = null!;
+ public DbSet ExternalRating { get; set; } = null!;
+ public DbSet ExternalSeriesMetadata { get; set; } = null!;
+ public DbSet ExternalRecommendation { get; set; } = null!;
+ public DbSet ManualMigrationHistory { get; set; } = null!;
+ public DbSet SeriesBlacklist { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
@@ -137,9 +143,15 @@ public sealed class DataContext : IdentityDbContext()
.HasIndex(e => e.Visible)
.IsUnique(false);
+
+ builder.Entity()
+ .HasOne(em => em.Series)
+ .WithOne(s => s.ExternalSeriesMetadata)
+ .HasForeignKey(em => em.SeriesId)
+ .OnDelete(DeleteBehavior.Cascade);
}
-
+ #nullable enable
private static void OnEntityTracked(object? sender, EntityTrackedEventArgs e)
{
if (e.FromQuery || e.Entry.State != EntityState.Added || e.Entry.Entity is not IEntityDate entity) return;
@@ -156,6 +168,7 @@ public sealed class DataContext : IdentityDbContext
+/// For the v0.7.14 release, one of the nightlies had bad data that would cause issues. This drops those records
+///
+public static class MigrateClearNightlyExternalSeriesRecords
+{
+ public static async Task Migrate(DataContext dataContext, ILogger logger)
+ {
+ if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateClearNightlyExternalSeriesRecords"))
+ {
+ return;
+ }
+
+ logger.LogCritical(
+ "Running MigrateClearNightlyExternalSeriesRecords migration - Please be patient, this may take some time. This is not an error");
+
+ dataContext.ExternalSeriesMetadata.RemoveRange(dataContext.ExternalSeriesMetadata);
+ dataContext.ExternalRating.RemoveRange(dataContext.ExternalRating);
+ dataContext.ExternalRecommendation.RemoveRange(dataContext.ExternalRecommendation);
+ dataContext.ExternalReview.RemoveRange(dataContext.ExternalReview);
+
+ dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
+ {
+ Name = "MigrateClearNightlyExternalSeriesRecords",
+ ProductVersion = BuildInfo.Version.ToString(),
+ RanAt = DateTime.UtcNow
+ });
+
+ await dataContext.SaveChangesAsync();
+
+ logger.LogCritical(
+ "Running MigrateClearNightlyExternalSeriesRecords migration - Completed. This is not an error");
+ }
+}
diff --git a/API/Data/ManualMigrations/MigrateEmailTemplates.cs b/API/Data/ManualMigrations/MigrateEmailTemplates.cs
new file mode 100644
index 000000000..ca0dc125b
--- /dev/null
+++ b/API/Data/ManualMigrations/MigrateEmailTemplates.cs
@@ -0,0 +1,59 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using API.Services;
+using Flurl.Http;
+using Microsoft.Extensions.Logging;
+
+namespace API.Data.ManualMigrations;
+
+public static class MigrateEmailTemplates
+{
+ private const string EmailChange = "https://raw.githubusercontent.com/Kareadita/KavitaEmail/main/KavitaEmail/config/templates/EmailChange.html";
+ private const string EmailConfirm = "https://raw.githubusercontent.com/Kareadita/KavitaEmail/main/KavitaEmail/config/templates/EmailConfirm.html";
+ private const string EmailPasswordReset = "https://raw.githubusercontent.com/Kareadita/KavitaEmail/main/KavitaEmail/config/templates/EmailPasswordReset.html";
+ private const string SendToDevice = "https://raw.githubusercontent.com/Kareadita/KavitaEmail/main/KavitaEmail/config/templates/SendToDevice.html";
+ private const string EmailTest = "https://raw.githubusercontent.com/Kareadita/KavitaEmail/main/KavitaEmail/config/templates/EmailTest.html";
+
+ public static async Task Migrate(IDirectoryService directoryService, ILogger logger)
+ {
+ var files = directoryService.GetFiles(directoryService.CustomizedTemplateDirectory);
+ if (files.Any())
+ {
+ logger.LogCritical("Running MigrateEmailTemplates migration - Completed. This is not an error");
+ return;
+ }
+
+ // Write files to directory
+ await DownloadAndWriteToFile(EmailChange, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailChange.html"), logger);
+ await DownloadAndWriteToFile(EmailConfirm, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailConfirm.html"), logger);
+ await DownloadAndWriteToFile(EmailPasswordReset, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailPasswordReset.html"), logger);
+ await DownloadAndWriteToFile(SendToDevice, Path.Join(directoryService.CustomizedTemplateDirectory, "SendToDevice.html"), logger);
+ await DownloadAndWriteToFile(EmailTest, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailTest.html"), logger);
+
+
+
+ logger.LogCritical("Running MigrateEmailTemplates migration - Please be patient, this may take some time. This is not an error");
+ }
+
+ private static async Task DownloadAndWriteToFile(string url, string filePath, ILogger logger)
+ {
+ try
+ {
+ // Download the raw text using Flurl
+ var content = await url.GetStringAsync();
+
+ // Write the content to a file
+ await File.WriteAllTextAsync(filePath, content);
+
+ logger.LogInformation("{File} downloaded and written successfully", filePath);
+ }
+ catch (FlurlHttpException ex)
+ {
+ logger.LogError(ex, "Unable to download {Url} to {FilePath}. Please perform yourself!", url, filePath);
+ }
+ }
+
+
+}
diff --git a/API/Data/ManualMigrations/MigrateLibrariesToHaveAllFileTypes.cs b/API/Data/ManualMigrations/MigrateLibrariesToHaveAllFileTypes.cs
index 1da2f6303..dfd5ab954 100644
--- a/API/Data/ManualMigrations/MigrateLibrariesToHaveAllFileTypes.cs
+++ b/API/Data/ManualMigrations/MigrateLibrariesToHaveAllFileTypes.cs
@@ -15,9 +15,20 @@ public static class MigrateLibrariesToHaveAllFileTypes
{
public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger logger)
{
+ if (await dataContext.Library.AnyAsync(l => l.LibraryFileTypes.Count == 0))
+ {
+ logger.LogCritical("Running MigrateLibrariesToHaveAllFileTypes migration - Completed. This is not an error");
+ return;
+ }
+
logger.LogCritical("Running MigrateLibrariesToHaveAllFileTypes migration - Please be patient, this may take some time. This is not an error");
- var allLibs = await dataContext.Library.Include(l => l.LibraryFileTypes).ToListAsync();
- foreach (var library in allLibs.Where(library => library.LibraryFileTypes.Count == 0))
+
+ var allLibs = await dataContext.Library
+ .Include(l => l.LibraryFileTypes)
+ .Where(library => library.LibraryFileTypes.Count == 0)
+ .ToListAsync();
+
+ foreach (var library in allLibs)
{
switch (library.Type)
{
@@ -57,11 +68,14 @@ public static class MigrateLibrariesToHaveAllFileTypes
});
break;
default:
- throw new ArgumentOutOfRangeException();
+ break;
}
}
- await dataContext.SaveChangesAsync();
+ if (unitOfWork.HasChanges())
+ {
+ await dataContext.SaveChangesAsync();
+ }
logger.LogCritical("Running MigrateLibrariesToHaveAllFileTypes migration - Completed. This is not an error");
}
}
diff --git a/API/Data/ManualMigrations/MigrateManualHistory.cs b/API/Data/ManualMigrations/MigrateManualHistory.cs
new file mode 100644
index 000000000..be41f0992
--- /dev/null
+++ b/API/Data/ManualMigrations/MigrateManualHistory.cs
@@ -0,0 +1,86 @@
+using System;
+using System.Threading.Tasks;
+using API.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace API.Data.ManualMigrations;
+
+///
+/// Introduced in v0.7.14, will store history so that going forward, migrations can just check against the history
+/// and I don't need to remove old migrations
+///
+public static class MigrateManualHistory
+{
+ public static async Task Migrate(DataContext dataContext, ILogger logger)
+ {
+ if (await dataContext.ManualMigrationHistory.AnyAsync())
+ {
+ logger.LogCritical(
+ "Running MigrateManualHistory migration - Completed. This is not an error");
+ return;
+ }
+
+ logger.LogCritical(
+ "Running MigrateManualHistory migration - Please be patient, this may take some time. This is not an error");
+
+ dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
+ {
+ Name = "MigrateUserLibrarySideNavStream",
+ ProductVersion = "0.7.9.0",
+ RanAt = DateTime.UtcNow
+ });
+
+ dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
+ {
+ Name = "MigrateSmartFilterEncoding",
+ ProductVersion = "0.7.11.0",
+ RanAt = DateTime.UtcNow
+ });
+ dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
+ {
+ Name = "MigrateLibrariesToHaveAllFileTypes",
+ ProductVersion = "0.7.11.0",
+ RanAt = DateTime.UtcNow
+ });
+
+ dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
+ {
+ Name = "MigrateEmailTemplates",
+ ProductVersion = "0.7.14.0",
+ RanAt = DateTime.UtcNow
+ });
+ dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
+ {
+ Name = "MigrateVolumeNumber",
+ ProductVersion = "0.7.14.0",
+ RanAt = DateTime.UtcNow
+ });
+
+ dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
+ {
+ Name = "MigrateWantToReadExport",
+ ProductVersion = "0.7.14.0",
+ RanAt = DateTime.UtcNow
+ });
+
+ dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
+ {
+ Name = "MigrateWantToReadImport",
+ ProductVersion = "0.7.14.0",
+ RanAt = DateTime.UtcNow
+ });
+
+ dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
+ {
+ Name = "MigrateManualHistory",
+ ProductVersion = "0.7.14.0",
+ RanAt = DateTime.UtcNow
+ });
+
+ await dataContext.SaveChangesAsync();
+
+ logger.LogCritical(
+ "Running MigrateManualHistory migration - Completed. This is not an error");
+ }
+}
diff --git a/API/Data/ManualMigrations/MigrateUserLibrarySideNavStream.cs b/API/Data/ManualMigrations/MigrateUserLibrarySideNavStream.cs
index d4220e7f7..290bd0dc9 100644
--- a/API/Data/ManualMigrations/MigrateUserLibrarySideNavStream.cs
+++ b/API/Data/ManualMigrations/MigrateUserLibrarySideNavStream.cs
@@ -14,9 +14,9 @@ public static class MigrateUserLibrarySideNavStream
{
public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger logger)
{
- logger.LogCritical("Running MigrateUserLibrarySideNavStream migration - Please be patient, this may take some time. This is not an error");
- var usersWithLibraryStreams = await dataContext.AppUser.Include(u => u.SideNavStreams)
+ var usersWithLibraryStreams = await dataContext.AppUser
+ .Include(u => u.SideNavStreams)
.AnyAsync(u => u.SideNavStreams.Count > 0 && u.SideNavStreams.Any(s => s.LibraryId > 0));
if (usersWithLibraryStreams)
@@ -25,6 +25,8 @@ public static class MigrateUserLibrarySideNavStream
return;
}
+ logger.LogCritical("Running MigrateUserLibrarySideNavStream migration - Please be patient, this may take some time. This is not an error");
+
var users = await unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.SideNavStreams);
foreach (var user in users)
{
diff --git a/API/Data/ManualMigrations/MigrateVolumeNumber.cs b/API/Data/ManualMigrations/MigrateVolumeNumber.cs
new file mode 100644
index 000000000..cae2e7f3c
--- /dev/null
+++ b/API/Data/ManualMigrations/MigrateVolumeNumber.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Threading.Tasks;
+using API.Entities.Enums;
+using API.Services.Tasks.Scanner.Parser;
+using Kavita.Common.EnvironmentInfo;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace API.Data.ManualMigrations;
+
+///
+/// Introduced in v0.7.14, this migrates the existing Volume Name -> Volume Min/Max Number
+///
+public static class MigrateVolumeNumber
+{
+ public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger logger)
+ {
+ if (await dataContext.Volume.AnyAsync(v => v.MaxNumber > 0))
+ {
+ logger.LogCritical(
+ "Running MigrateVolumeNumber migration - Completed. This is not an error");
+ return;
+ }
+
+ logger.LogCritical(
+ "Running MigrateVolumeNumber migration - Please be patient, this may take some time. This is not an error");
+
+ // Get all volumes
+ foreach (var volume in dataContext.Volume)
+ {
+ volume.MinNumber = Parser.MinNumberFromRange(volume.Name);
+ volume.MaxNumber = Parser.MaxNumberFromRange(volume.Name);
+ }
+
+ await dataContext.SaveChangesAsync();
+ logger.LogCritical(
+ "Running MigrateVolumeNumber migration - Completed. This is not an error");
+ }
+}
diff --git a/API/Data/ManualMigrations/MigrateWantToReadExport.cs b/API/Data/ManualMigrations/MigrateWantToReadExport.cs
new file mode 100644
index 000000000..cff05b9a8
--- /dev/null
+++ b/API/Data/ManualMigrations/MigrateWantToReadExport.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Threading.Tasks;
+using API.Services;
+using CsvHelper;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace API.Data.ManualMigrations;
+
+
+///
+/// v0.7.13.12/v0.7.14 - Want to read is extracted and saved in a csv
+///
+/// This must run BEFORE any DB migrations
+public static class MigrateWantToReadExport
+{
+ public static async Task Migrate(DataContext dataContext, IDirectoryService directoryService, ILogger logger)
+ {
+ try
+ {
+ var importFile = Path.Join(directoryService.ConfigDirectory, "want-to-read-migration.csv");
+ if (File.Exists(importFile))
+ {
+ logger.LogCritical(
+ "Running MigrateWantToReadExport migration - Completed. This is not an error");
+ return;
+ }
+
+ logger.LogCritical(
+ "Running MigrateWantToReadExport migration - Please be patient, this may take some time. This is not an error");
+
+ await using var command = dataContext.Database.GetDbConnection().CreateCommand();
+ command.CommandText = "Select AppUserId, Id from Series WHERE AppUserId IS NOT NULL ORDER BY AppUserId;";
+
+ await dataContext.Database.OpenConnectionAsync();
+ await using var result = await command.ExecuteReaderAsync();
+
+ await using var writer =
+ new StreamWriter(Path.Join(directoryService.ConfigDirectory, "want-to-read-migration.csv"));
+ await using var csvWriter = new CsvWriter(writer, CultureInfo.InvariantCulture);
+
+ // Write header
+ csvWriter.WriteField("AppUserId");
+ csvWriter.WriteField("Id");
+ await csvWriter.NextRecordAsync();
+
+ // Write data
+ while (await result.ReadAsync())
+ {
+ var appUserId = result["AppUserId"].ToString();
+ var id = result["Id"].ToString();
+
+ csvWriter.WriteField(appUserId);
+ csvWriter.WriteField(id);
+ await csvWriter.NextRecordAsync();
+ }
+
+
+ try
+ {
+ await dataContext.Database.CloseConnectionAsync();
+ writer.Close();
+ }
+ catch (Exception)
+ {
+ /* Swallow */
+ }
+
+ logger.LogCritical(
+ "Running MigrateWantToReadExport migration - Completed. This is not an error");
+ }
+ catch (Exception ex)
+ {
+ // On new installs, the db isn't setup yet, so this has nothing to do
+ }
+ }
+}
diff --git a/API/Data/ManualMigrations/MigrateWantToReadImport.cs b/API/Data/ManualMigrations/MigrateWantToReadImport.cs
new file mode 100644
index 000000000..01982e58f
--- /dev/null
+++ b/API/Data/ManualMigrations/MigrateWantToReadImport.cs
@@ -0,0 +1,60 @@
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using API.Data.Repositories;
+using API.Entities;
+using API.Services;
+using CsvHelper;
+using Microsoft.Extensions.Logging;
+
+namespace API.Data.ManualMigrations;
+
+///
+/// v0.7.13.12/v0.7.14 - Want to read is imported from a csv
+///
+public static class MigrateWantToReadImport
+{
+ public static async Task Migrate(IUnitOfWork unitOfWork, IDirectoryService directoryService, ILogger logger)
+ {
+ var importFile = Path.Join(directoryService.ConfigDirectory, "want-to-read-migration.csv");
+ var outputFile = Path.Join(directoryService.ConfigDirectory, "imported-want-to-read-migration.csv");
+
+ if (!File.Exists(importFile) || File.Exists(outputFile))
+ {
+ logger.LogCritical(
+ "Running MigrateWantToReadImport migration - Completed. This is not an error");
+ return;
+ }
+
+ logger.LogCritical(
+ "Running MigrateWantToReadImport migration - Please be patient, this may take some time. This is not an error");
+
+ using var reader = new StreamReader(importFile);
+ using var csvReader = new CsvReader(reader, CultureInfo.InvariantCulture);
+ // Read the records from the CSV file
+ await csvReader.ReadAsync();
+ csvReader.ReadHeader(); // Skip the header row
+
+ while (await csvReader.ReadAsync())
+ {
+ // Read the values of AppUserId and Id columns
+ var appUserId = csvReader.GetField("AppUserId");
+ var seriesId = csvReader.GetField("Id");
+ var user = await unitOfWork.UserRepository.GetUserByIdAsync(appUserId, AppUserIncludes.WantToRead);
+ if (user == null || user.WantToRead.Any(w => w.SeriesId == seriesId)) continue;
+
+ user.WantToRead.Add(new AppUserWantToRead()
+ {
+ SeriesId = seriesId
+ });
+ }
+
+ await unitOfWork.CommitAsync();
+ reader.Close();
+
+ File.WriteAllLines(outputFile, await File.ReadAllLinesAsync(importFile));
+ logger.LogCritical(
+ "Running MigrateWantToReadImport migration - Completed. This is not an error");
+ }
+}
diff --git a/API/Data/Migrations/20240121223643_ExternalSeriesMetadata.Designer.cs b/API/Data/Migrations/20240121223643_ExternalSeriesMetadata.Designer.cs
new file mode 100644
index 000000000..e7fdad65e
--- /dev/null
+++ b/API/Data/Migrations/20240121223643_ExternalSeriesMetadata.Designer.cs
@@ -0,0 +1,2787 @@
+//
+using System;
+using API.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace API.Data.Migrations
+{
+ [DbContext(typeof(DataContext))]
+ [Migration("20240121223643_ExternalSeriesMetadata")]
+ partial class ExternalSeriesMetadata
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "8.0.1");
+
+ modelBuilder.Entity("API.Entities.AppRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("API.Entities.AppUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRestriction")
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRestrictionIncludeUnknowns")
+ .HasColumnType("INTEGER");
+
+ b.Property("AniListAccessToken")
+ .HasColumnType("TEXT");
+
+ b.Property("ApiKey")
+ .HasColumnType("TEXT");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("ConfirmationToken")
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastActive")
+ .HasColumnType("TEXT");
+
+ b.Property("LastActiveUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("TEXT");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("FileName")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Page")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserBookmark");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserDashboardStream", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsProvided")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property("SmartFilterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("StreamType")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(4);
+
+ b.Property("Visible")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SmartFilterId");
+
+ b.HasIndex("Visible");
+
+ b.ToTable("AppUserDashboardStream");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserExternalSource", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ApiKey")
+ .HasColumnType("TEXT");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Host")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserExternalSource");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserOnDeckRemoval");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("AutoCloseMenu")
+ .HasColumnType("INTEGER");
+
+ b.Property("BackgroundColor")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("#000000");
+
+ b.Property("BlurUnreadSummaries")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderFontFamily")
+ .HasColumnType("TEXT");
+
+ b.Property("BookReaderFontSize")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderImmersiveMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderLayoutMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderLineSpacing")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderMargin")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderTapToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderWritingStyle")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("BookThemeName")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("Dark");
+
+ b.Property("CollapseSeriesRelationships")
+ .HasColumnType("INTEGER");
+
+ b.Property("EmulateBook")
+ .HasColumnType("INTEGER");
+
+ b.Property("GlobalPageLayoutMode")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("LayoutMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("Locale")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("en");
+
+ b.Property("NoTransitions")
+ .HasColumnType("INTEGER");
+
+ b.Property("PageSplitOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("PromptForDownloadSize")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReaderMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("ScalingOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShareReviews")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowScreenHints")
+ .HasColumnType("INTEGER");
+
+ b.Property("SwipeToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("ThemeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId")
+ .IsUnique();
+
+ b.HasIndex("ThemeId");
+
+ b.ToTable("AppUserPreferences");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserProgress", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookScrollId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PagesRead")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("ChapterId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserProgresses");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserRating", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("HasBeenRated")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("REAL");
+
+ b.Property("Review")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Tagline")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserRating");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RoleId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserSideNavStream", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ExternalSourceId")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsProvided")
+ .HasColumnType("INTEGER");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property("SmartFilterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("StreamType")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(5);
+
+ b.Property("Visible")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SmartFilterId");
+
+ b.HasIndex("Visible");
+
+ b.ToTable("AppUserSideNavStream");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserSmartFilter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Filter")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserSmartFilter");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserTableOfContent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookScrollId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PageNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .HasColumnType("TEXT");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("ChapterId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserTableOfContent");
+ });
+
+ modelBuilder.Entity("API.Entities.Chapter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRating")
+ .HasColumnType("INTEGER");
+
+ b.Property("AlternateCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("AlternateNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("AlternateSeries")
+ .HasColumnType("TEXT");
+
+ b.Property("AvgHoursToRead")
+ .HasColumnType("INTEGER");
+
+ b.Property("Count")
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverImage")
+ .HasColumnType("TEXT");
+
+ b.Property("CoverImageLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("ISBN")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("");
+
+ b.Property("IsSpecial")
+ .HasColumnType("INTEGER");
+
+ b.Property("Language")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("MaxHoursToRead")
+ .HasColumnType("INTEGER");
+
+ b.Property("MinHoursToRead")
+ .HasColumnType("INTEGER");
+
+ b.Property("Number")
+ .HasColumnType("TEXT");
+
+ b.Property("Pages")
+ .HasColumnType("INTEGER");
+
+ b.Property("Range")
+ .HasColumnType("TEXT");
+
+ b.Property("ReleaseDate")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesGroup")
+ .HasColumnType("TEXT");
+
+ b.Property("StoryArc")
+ .HasColumnType("TEXT");
+
+ b.Property("StoryArcNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("Summary")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasColumnType("TEXT");
+
+ b.Property("TitleName")
+ .HasColumnType("TEXT");
+
+ b.Property("TotalCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.Property("WebLinks")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("");
+
+ b.Property("WordCount")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("VolumeId");
+
+ b.ToTable("Chapter");
+ });
+
+ modelBuilder.Entity("API.Entities.CollectionTag", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverImage")
+ .HasColumnType("TEXT");
+
+ b.Property("CoverImageLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("NormalizedTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("Promoted")
+ .HasColumnType("INTEGER");
+
+ b.Property("RowVersion")
+ .HasColumnType("INTEGER");
+
+ b.Property("Summary")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Id", "Promoted")
+ .IsUnique();
+
+ b.ToTable("CollectionTag");
+ });
+
+ modelBuilder.Entity("API.Entities.Device", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("EmailAddress")
+ .HasColumnType("TEXT");
+
+ b.Property("IpAddress")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastUsed")
+ .HasColumnType("TEXT");
+
+ b.Property("LastUsedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Platform")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("Device");
+ });
+
+ modelBuilder.Entity("API.Entities.FolderPath", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("LastScanned")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Path")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LibraryId");
+
+ b.ToTable("FolderPath");
+ });
+
+ modelBuilder.Entity("API.Entities.Genre", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("NormalizedTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedTitle")
+ .IsUnique();
+
+ b.ToTable("Genre");
+ });
+
+ modelBuilder.Entity("API.Entities.Library", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AllowScrobbling")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(true);
+
+ b.Property("CoverImage")
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("FolderWatching")
+ .HasColumnType("INTEGER");
+
+ b.Property("IncludeInDashboard")
+ .HasColumnType("INTEGER");
+
+ b.Property("IncludeInRecommended")
+ .HasColumnType("INTEGER");
+
+ b.Property("IncludeInSearch")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastScanned")
+ .HasColumnType("TEXT");
+
+ b.Property("ManageCollections")
+ .HasColumnType("INTEGER");
+
+ b.Property("ManageReadingLists")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("Library");
+ });
+
+ modelBuilder.Entity("API.Entities.LibraryExcludePattern", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Pattern")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LibraryId");
+
+ b.ToTable("LibraryExcludePattern");
+ });
+
+ modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("FileTypeGroup")
+ .HasColumnType("INTEGER");
+
+ b.Property