Merged develop in

This commit is contained in:
Joseph Milazzo 2024-02-10 09:53:14 -06:00
commit a443be7523
322 changed files with 31244 additions and 6350 deletions

View file

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

View file

@ -1 +1,5 @@
blank_issues_enabled: false
blank_issues_enabled: false
contact_links:
- name: Feature Requests
url: https://github.com/Kareadita/Kavita/discussions
about: Suggest an idea for the Kavita project

View file

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

View file

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

3
.gitignore vendored
View file

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

View file

@ -10,8 +10,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.13.11" />
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.13.11" />
<PackageReference Include="BenchmarkDotNet" Version="0.13.12" />
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.13.12" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
</ItemGroup>

View file

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

View file

@ -6,13 +6,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.4" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="20.0.4" />
<PackageReference Include="xunit" Version="2.6.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.5">
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.15" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="20.0.15" />
<PackageReference Include="xunit" Version="2.6.6" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -180,7 +180,7 @@ Substitute.For<IMediaConversionService>());
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<IMediaConversionService>());
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<IMediaConversionService>());
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<IMediaConversionService>());
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<IMediaConversionService>());
var series = new SeriesBuilder("Test")
.WithFormat(MangaFormat.Epub)
.WithVolume(new VolumeBuilder("1")
.WithNumber(1)
.WithMinNumber(1)
.WithChapter(new ChapterBuilder("1")
.Build())
.Build())

View file

@ -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<Series>()
{
s
}
};
_context.AppUser.Add(user);
await _unitOfWork.CommitAsync();
// Add want to read
user.WantToRead = new List<AppUserWantToRead>()
{
new AppUserWantToRead()
{
SeriesId = s.Id
}
};
await _unitOfWork.CommitAsync();
await _readerService.MarkSeriesAsRead(user, s.Id);
await _unitOfWork.CommitAsync();

View file

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

View file

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

View file

@ -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<ExternalRating>()
{
new ExternalRating()
{
SeriesId = 1,
Provider = ScrobbleProvider.Mal,
AverageScore = 1
}
};
series1.ExternalSeriesMetadata.ExternalRecommendations = new List<ExternalRecommendation>()
{
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<ExternalReview>()
{
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
}

View file

@ -53,7 +53,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
<PackageReference Include="CsvHelper" Version="30.1.0" />
<PackageReference Include="MailKit" Version="4.3.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@ -63,28 +65,27 @@
<PackageReference Include="ExCSS" Version="4.2.4" />
<PackageReference Include="Flurl" Version="3.0.7" />
<PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Hangfire" Version="1.8.6" />
<PackageReference Include="Hangfire.InMemory" Version="0.6.0" />
<PackageReference Include="Hangfire" Version="1.8.9" />
<PackageReference Include="Hangfire.InMemory" Version="0.7.0" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.MemoryStorage.Core" Version="1.4.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.3.4" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.54" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.58" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.6" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.9" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.0" />
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
<PackageReference Include="NetVips" Version="2.4.0" />
<PackageReference Include="NetVips.Native" Version="8.15.0" />
<PackageReference Include="NReco.Logging.File" Version="1.1.7" />
<PackageReference Include="NetVips.Native" Version="8.15.1" />
<PackageReference Include="NReco.Logging.File" Version="1.2.0" />
<PackageReference Include="Serilog" Version="3.1.1" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.2.0-dev-00752" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.0" />
@ -92,17 +93,17 @@
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.34.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.1" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.15.0.81779">
<PackageReference Include="SharpCompress" Version="0.36.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.2" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.19.0.84025">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="7.0.12" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.0.3" />
<PackageReference Include="System.IO.Abstractions" Version="20.0.4" />
<PackageReference Include="System.Drawing.Common" Version="8.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.3.1" />
<PackageReference Include="System.IO.Abstractions" Version="20.0.15" />
<PackageReference Include="System.Drawing.Common" Version="8.0.1" />
<PackageReference Include="VersOne.Epub" Version="3.3.1" />
</ItemGroup>
@ -190,6 +191,9 @@
<ItemGroup>
<Folder Include="config\themes" />
<Content Include="EmailTemplates\**">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<None Include="I18N\**" />
</ItemGroup>

View file

@ -16,11 +16,7 @@ public static class EasyCacheProfiles
/// </summary>
public const string Library = "library";
/// <summary>
/// Metadata filter
/// External Series metadata for Kavita+ recommendation
/// </summary>
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";
}

View file

@ -35,7 +35,13 @@ public static class PolicyConstants
/// Used to give a user ability to Login to their account
/// </summary>
public const string LoginRole = "Login";
/// <summary>
/// Restricts the ability to manage their account without an admin
/// </summary>
/// <remarks>This is used explicitly for Demo Server. Not sure why it would be used in another fashion</remarks>
public const string ReadOnlyRole = "Read Only";
public static readonly ImmutableArray<string> ValidRoles =
ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole);
ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole, ReadOnlyRole);
}

View file

@ -77,10 +77,11 @@ public class AccountController : BaseApiController
[HttpPost("reset-password")]
public async Task<ActionResult> 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<ActionResult<string>> 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
/// <summary>
/// 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.
/// </summary>
/// <param name="dto"></param>
@ -343,7 +349,7 @@ public class AccountController : BaseApiController
public async Task<ActionResult> 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);
}
/// <summary>
/// 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.
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
@ -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<ActionResult<string>> 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
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpPost("resend-confirmation-email")]
[EnableRateLimiting("Authentication")]
public async Task<ActionResult<string>> ResendConfirmationSendEmail([FromQuery] int userId)
public async Task<ActionResult<InviteUserResponse>> 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)
});
}
/// <summary>
/// This is similar to invite. Essentially we authenticate the user's password then go through invite email flow
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[AllowAnonymous]
[HttpPost("migrate-email")]
public async Task<ActionResult<string>> 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<bool> ConfirmEmailToken(string token, AppUser user)
{
var result = await _userManager.ConfirmEmailAsync(user, token);

View file

@ -92,18 +92,28 @@ public class DeviceController : BaseApiController
return Ok(await _unitOfWork.DeviceRepository.GetDevicesForUserAsync(User.GetUserId()));
}
/// <summary>
/// Sends a collection of chapters to the user's device
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("send-to")]
public async Task<ActionResult> 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"));
}
}

View file

@ -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<MangaFile> files)
private PhysicalFileResult GetFirstFileDownload(IEnumerable<MangaFile> 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<ActionResult> DownloadFiles(ICollection<MangaFile> 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<string, float> 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);
}
}

View file

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

View file

@ -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<LicenseController> logger,
ILicenseService licenseService,
ILocalizationService localizationService)
ILocalizationService localizationService,
ITaskScheduler taskScheduler)
: BaseApiController
{
/// <summary>
@ -31,7 +31,9 @@ public class LicenseController(
[ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)]
public async Task<ActionResult<bool>> HasValidLicense(bool forceCheck = false)
{
return Ok(await licenseService.HasActiveLicense(forceCheck));
var result = await licenseService.HasActiveLicense(forceCheck);
await taskScheduler.ScheduleKavitaPlusTasks();
return Ok(result);
}
/// <summary>
@ -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<ActionResult> 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)
{

View file

@ -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_";
/// <summary>
/// Fetches genres from the instance
@ -39,12 +37,12 @@ public class MetadataController : BaseApiController
public async Task<ActionResult<IList<GenreTagDto>>> 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()));
}
/// <summary>
@ -53,12 +51,12 @@ public class MetadataController : BaseApiController
/// <param name="role">role</param>
/// <returns></returns>
[HttpGet("people-by-role")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"role"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["role"])]
public async Task<ActionResult<IList<PersonDto>>> 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()));
}
/// <summary>
@ -67,15 +65,15 @@ public class MetadataController : BaseApiController
/// <param name="libraryIds">String separated libraryIds or null for all people</param>
/// <returns></returns>
[HttpGet("people")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["libraryIds"])]
public async Task<ActionResult<IList<PersonDto>>> 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()));
}
/// <summary>
@ -84,15 +82,15 @@ public class MetadataController : BaseApiController
/// <param name="libraryIds">String separated libraryIds or null for all tags</param>
/// <returns></returns>
[HttpGet("tags")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["libraryIds"])]
public async Task<ActionResult<IList<TagDto>>> 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()));
}
/// <summary>
@ -101,14 +99,14 @@ public class MetadataController : BaseApiController
/// <param name="libraryIds">String separated libraryIds or null for all ratings</param>
/// <remarks>This API is cached for 1 hour, varying by libraryIds</remarks>
/// <returns></returns>
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new [] {"libraryIds"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = ["libraryIds"])]
[HttpGet("age-ratings")]
public async Task<ActionResult<IList<AgeRatingDto>>> 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<AgeRating>().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<PublicationStatus>().Select(t => new PublicationStatusDto()
@ -152,10 +150,13 @@ public class MetadataController : BaseApiController
public async Task<ActionResult<IList<LanguageDto>>> 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));
}
/// <summary>
/// Returns all languages Kavita can accept
/// </summary>
/// <returns></returns>
[HttpGet("all-languages")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)]
public IEnumerable<LanguageDto> GetAllValidLanguages()
@ -177,9 +178,68 @@ public class MetadataController : BaseApiController
[HttpGet("chapter-summary")]
public async Task<ActionResult<string>> 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);
}
/// <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.
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpPost("force-refresh")]
public async Task<ActionResult> ForceRefresh(int seriesId)
{
await metadataService.ForceKavitaPlusRefresh(seriesId);
return Ok();
}
/// <summary>
/// Fetches the details needed from Kavita+ for Series Detail page
/// </summary>
/// <remarks>This will hit upstream K+ if the data in local db is 2 weeks old</remarks>
/// <param name="seriesId">Series Id</param>
/// <param name="libraryType">Library Type</param>
/// <returns></returns>
[HttpGet("series-detail-plus")]
public async Task<ActionResult<SeriesDetailPlusDto>> 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<UserReviewDto> 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<ExternalSeriesDto>();
}
if (ret.Recommendations != null && user != null)
{
ret.Recommendations.OwnedSeries ??= new List<SeriesDto>();
await unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, ret.Recommendations.OwnedSeries);
}
}
}

View file

@ -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
/// <returns></returns>
[HttpGet("{apiKey}/smart-filter/{filterId}")]
[Produces("application/xml")]
public async Task<IActionResult> GetSmartFilter(string apiKey, int filterId)
public async Task<IActionResult> 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;

View file

@ -57,7 +57,7 @@ public class PanelsController : BaseApiController
PageNum = 0,
ChapterId = chapterId,
VolumeId = 0,
SeriesId = 0
SeriesId = 0,
});
return Ok(progress);
}

View file

@ -14,19 +14,9 @@ namespace API.Controllers;
#nullable enable
public class PluginController : BaseApiController
public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService, ILogger<PluginController> logger)
: BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITokenService _tokenService;
private readonly ILogger<PluginController> _logger;
public PluginController(IUnitOfWork unitOfWork, ITokenService tokenService, ILogger<PluginController> logger)
{
_unitOfWork = unitOfWork;
_tokenService = tokenService;
_logger = logger;
}
/// <summary>
/// Authenticate with the Server given an apiKey. This will log you in by returning the user object and the JWT token.
/// </summary>
@ -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<ActionResult<string>> 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);
}
}

View file

@ -20,50 +20,12 @@ namespace API.Controllers;
/// </summary>
public class RatingController : BaseApiController
{
private readonly ILicenseService _licenseService;
private readonly IRatingService _ratingService;
private readonly ILogger<RatingController> _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly IEasyCachingProvider _cacheProvider;
public const string CacheKey = "rating_";
public RatingController(ILicenseService licenseService, IRatingService ratingService,
ILogger<RatingController> logger, IEasyCachingProviderFactory cachingProviderFactory, IUnitOfWork unitOfWork)
public RatingController(IUnitOfWork unitOfWork)
{
_licenseService = licenseService;
_ratingService = ratingService;
_logger = logger;
_unitOfWork = unitOfWork;
_cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings);
}
/// <summary>
/// Get the external ratings for a given series
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpGet]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})]
public async Task<ActionResult<IEnumerable<RatingDto>>> GetRating(int seriesId)
{
if (!await _licenseService.HasActiveLicense())
{
return Ok(Enumerable.Empty<RatingDto>());
}
var cacheKey = CacheKey + seriesId;
var results = await _cacheProvider.GetAsync<IEnumerable<RatingDto>>(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")]

View file

@ -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);
}
/// <summary>
/// For Kavita+ users, this will return recommendations on the server.
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpGet("recommendations")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})]
public async Task<ActionResult<RecommendationDto>> 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<RecommendationDto>(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);
}
/// <summary>
/// Quick Reads are series that should be readable in less than 10 in total and are not Ongoing in release.
/// </summary>
@ -79,7 +27,7 @@ public class RecommendedController : BaseApiController
/// <param name="userParams">Pagination</param>
/// <returns></returns>
[HttpGet("quick-reads")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetQuickReads(int libraryId, [FromQuery] UserParams userParams)
public async Task<ActionResult<PagedList<SeriesDto>>> 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
/// <param name="userParams"></param>
/// <returns></returns>
[HttpGet("quick-catchup-reads")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetQuickCatchupReads(int libraryId, [FromQuery] UserParams userParams)
public async Task<ActionResult<PagedList<SeriesDto>>> 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
/// <param name="userParams">Pagination</param>
/// <returns></returns>
[HttpGet("highly-rated")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetHighlyRated(int libraryId, [FromQuery] UserParams userParams)
public async Task<ActionResult<PagedList<SeriesDto>>> GetHighlyRated(int libraryId, [FromQuery] UserParams? userParams)
{
var userId = User.GetUserId();
userParams ??= UserParams.Default;
@ -129,7 +77,7 @@ public class RecommendedController : BaseApiController
/// <param name="userParams">Pagination</param>
/// <returns></returns>
[HttpGet("more-in")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetMoreIn(int libraryId, int genreId, [FromQuery] UserParams userParams)
public async Task<ActionResult<PagedList<SeriesDto>>> GetMoreIn(int libraryId, int genreId, [FromQuery] UserParams? userParams)
{
var userId = User.GetUserId();
@ -148,7 +96,7 @@ public class RecommendedController : BaseApiController
/// <param name="userParams">Pagination</param>
/// <returns></returns>
[HttpGet("rediscover")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetRediscover(int libraryId, [FromQuery] UserParams userParams)
public async Task<ActionResult<PagedList<SeriesDto>>> GetRediscover(int libraryId, [FromQuery] UserParams? userParams)
{
userParams ??= UserParams.Default;
var series = await _unitOfWork.SeriesRepository.GetRediscover(User.GetUserId(), libraryId, userParams);

View file

@ -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<ReviewController> _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<ReviewController> 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);
}
/// <summary>
/// Fetches reviews from the server for a given series
/// </summary>
/// <param name="seriesId"></param>
[HttpGet]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.KavitaPlus, VaryByQueryKeys = new []{"seriesId"})]
public async Task<ActionResult<IEnumerable<UserReviewDto>>> 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<UserReviewDto> externalReviews;
var result = await _cacheProvider.GetAsync<IEnumerable<UserReviewDto>>(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<UserReviewDto> SelectSpectrumOfReviews(IList<UserReviewDto> reviews)
{
IList<UserReviewDto> externalReviews;
var totalReviews = reviews.Count;
if (totalReviews > 10)
{
var stepSize = Math.Max((totalReviews - 4) / 8, 1);
var selectedReviews = new List<UserReviewDto>()
{
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;
}
/// <summary>
/// Updates the review for a given series
/// </summary>
@ -157,4 +61,23 @@ public class ReviewController : BaseApiController
_scrobblingService.ScrobbleReviewUpdate(user.Id, dto.SeriesId, string.Empty, dto.Body));
return Ok(_mapper.Map<UserReviewDto>(rating));
}
/// <summary>
/// Deletes the user's review for the given series
/// </summary>
/// <returns></returns>
[HttpDelete]
public async Task<ActionResult> 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();
}
}

View file

@ -39,22 +39,27 @@ public class ScrobblingController : BaseApiController
_localizationService = localizationService;
}
/// <summary>
/// Get the current user's AniList token
/// </summary>
/// <returns></returns>
[HttpGet("anilist-token")]
public async Task<ActionResult> GetAniListToken()
public async Task<ActionResult<string>> GetAniListToken()
{
// Validate the license
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized();
return Ok(user.AniListAccessToken);
}
/// <summary>
/// Update the current user's AniList token
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("update-anilist-token")]
public async Task<ActionResult> 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();
}
/// <summary>
/// Checks if the current Scrobbling token for the given Provider has expired for the current user
/// </summary>
/// <param name="provider"></param>
/// <returns></returns>
[HttpGet("token-expired")]
public async Task<ActionResult<bool>> 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)

View file

@ -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<SeriesController> 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);
}
/// <summary>
/// Deletes a series from Kavita
/// </summary>
/// <param name="seriesId"></param>
/// <returns>If the series was deleted or not</returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpDelete("{seriesId}")]
public async Task<ActionResult<bool>> 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");
}

View file

@ -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<ServerController> 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;
}
/// <summary>
@ -180,15 +176,35 @@ public class ServerController : BaseApiController
}
}
/// <summary>
/// Checks for updates and pushes an event to the UI
/// </summary>
/// <remarks>Some users have websocket issues so this is not always reliable to alert the user</remarks>
[HttpGet("check-for-updates")]
public async Task<ActionResult> CheckForAnnouncements()
{
await _taskScheduler.CheckForUpdate();
return Ok();
}
/// <summary>
/// Checks for updates, if no updates that are > current version installed, returns null
/// </summary>
[HttpGet("check-update")]
public async Task<ActionResult<UpdateNotificationDto>> CheckForUpdates()
public async Task<ActionResult<UpdateNotificationDto?>> CheckForUpdates()
{
return Ok(await _versionUpdaterService.CheckForUpdate());
}
/// <summary>
/// Returns how many versions out of date this install is
/// </summary>
[HttpGet("check-out-of-date")]
public async Task<ActionResult<int>> CheckHowOutOfDate()
{
return Ok(await _versionUpdaterService.GetNumberOfReleasesBehind());
}
/// <summary>
/// Pull the Changelog for Kavita from Github and display
@ -200,18 +216,6 @@ public class ServerController : BaseApiController
return Ok(await _versionUpdaterService.GetAllReleases());
}
/// <summary>
/// Is this server accessible to the outside net
/// </summary>
/// <remarks>If the instance has the HostName set, this will return true whether or not it is accessible externally</remarks>
/// <returns></returns>
[HttpGet("accessible")]
[AllowAnonymous]
public async Task<ActionResult<bool>> IsServerAccessible()
{
return Ok(await _accountService.CheckIfAccessible(Request));
}
/// <summary>
/// Returns a list of reoccurring jobs. Scheduled ad-hoc jobs will not be returned.
/// </summary>
@ -260,35 +264,13 @@ public class ServerController : BaseApiController
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpPost("bust-review-and-rec-cache")]
[HttpPost("bust-kavitaplus-cache")]
public async Task<ActionResult> 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();
}
/// <summary>
/// Returns the KavitaEmail version for non-default instances
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpGet("email-version")]
public async Task<ActionResult<string?>> GetEmailVersion()
{
var emailServiceUrl = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl))
.Value;
if (emailServiceUrl.Equals(EmailService.DefaultApiUrl)) return Ok(null);
return Ok(await _emailService.GetVersion(emailServiceUrl));
}
}

View file

@ -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);
}
/// <summary>
/// Returns the server settings
/// </summary>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet]
public async Task<ActionResult<ServerSettingDto>> GetSettings()
@ -119,38 +125,15 @@ public class SettingsController : BaseApiController
}
/// <summary>
/// Resets the email service url
/// Is the minimum information setup for Email to work
/// </summary>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("reset-email-url")]
public async Task<ActionResult<ServerSettingDto>> ResetEmailServiceUrlSettings()
[HttpGet("is-email-setup")]
public async Task<ActionResult<bool>> 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());
}
/// <summary>
/// Sends a test email from the Email Service. Will not send if email service is the Default Provider
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("test-email-url")]
public async Task<ActionResult<EmailTestResultDto>> 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);
}
}
/// <summary>
/// All values allowed for Task Scheduling APIs. A custom cron job is not included. Disabled is not applicable for Cleanup.
/// </summary>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("task-frequencies")]
public ActionResult<IEnumerable<string>> GetTaskFrequencies()
@ -410,7 +479,7 @@ public class SettingsController : BaseApiController
[HttpGet("log-levels")]
public ActionResult<IEnumerable<string>> 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);
}
/// <summary>
/// Is the cron expression valid for Kavita's scheduler
/// </summary>
/// <param name="cronExpression"></param>
/// <returns></returns>
[HttpGet("is-valid-cron")]
public ActionResult<bool> IsValidCron(string cronExpression)
{
// NOTE: This must match Hangfire's underlying cron system. Hangfire is unique
return Ok(CronHelper.IsValidCron(cronExpression));
}
/// <summary>
/// Sends a test email to see if email settings are hooked up correctly
/// </summary>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("test-email-url")]
public async Task<ActionResult<EmailTestResultDto>> TestEmailServiceUrl()
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId());
return Ok(await _emailService.SendTestEmail(user!.Email));
}
}

View file

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

View file

@ -7,4 +7,5 @@ public class EmailTestResultDto
{
public bool Successful { get; set; }
public string ErrorMessage { get; set; } = default!;
public string EmailAddress { get; set; } = default!;
}

View file

@ -30,4 +30,8 @@ public enum SortField
/// Last time the user had any reading progress
/// </summary>
ReadProgress = 7,
/// <summary>
/// Kavita+ Only - External Average Rating
/// </summary>
AverageRating = 8
}

View file

@ -45,5 +45,9 @@ public enum FilterField
/// Last time User Read
/// </summary>
ReadingDate = 27,
/// <summary>
/// Average rating from Kavita+ - Not usable for non-licensed users
/// </summary>
AverageRating = 28
}

View file

@ -39,7 +39,7 @@ public class FeedLink
/// </summary>
/// <remarks>Attribute MUST conform Atom's Date construct</remarks>
[XmlAttribute("lastReadDate", Namespace = "http://vaemendis.net/opds-pse/ns")]
public DateTime LastReadDate { get; set; }
public string LastReadDate { get; set; }
public bool ShouldSerializeLastReadDate()
{

View file

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

View file

@ -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; }
/// <summary>
/// Optional but can help with matching
/// </summary>
public int? ChapterCount { get; set; }
/// <summary>
/// Optional but can help with matching
/// </summary>
public int? VolumeCount { get; set; }
public int? Year { get; set; }
}

View file

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

View file

@ -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; }
/// <summary>
/// Null if not applicable
/// </summary>

View file

@ -0,0 +1,15 @@
using System.Collections.Generic;
using API.DTOs.Recommendation;
namespace API.DTOs.SeriesDetail;
/// <summary>
/// All the data from Kavita+ for Series Detail
/// </summary>
/// <remarks>This is what the UI sees, not what the API sends back</remarks>
public class SeriesDetailPlusDto
{
public RecommendationDto? Recommendations { get; set; }
public IEnumerable<UserReviewDto> Reviews { get; set; }
public IEnumerable<RatingDto>? Ratings { get; set; }
}

View file

@ -14,27 +14,29 @@ public class UserReviewDto
/// </summary>
/// <remarks>This is not possible to set as a local user</remarks>
public string? Tagline { get; set; }
/// <summary>
/// The main review
/// </summary>
public string Body { get; set; }
/// <summary>
/// The main body with just text, for review preview
/// </summary>
public string? BodyJustText { get; set; }
/// <summary>
/// The series this is for
/// </summary>
public int SeriesId { get; set; }
/// <summary>
/// The library this series belongs in
/// </summary>
public int LibraryId { get; set; }
/// <summary>
/// The user who wrote this
/// </summary>
public string Username { get; set; }
public int TotalVotes { get; set; }
public float Rating { get; set; }
public string? RawBody { get; set; }
/// <summary>
/// How many upvotes this review has gotten
/// </summary>
@ -43,16 +45,11 @@ public class UserReviewDto
/// <summary>
/// If External, the url of the review
/// </summary>
public string? ExternalUrl { get; set; }
public string? SiteUrl { get; set; }
/// <summary>
/// Does this review come from an external Source
/// </summary>
public bool IsExternal { get; set; }
/// <summary>
/// The main body with just text, for review preview
/// </summary>
public string? BodyJustText { get; set; }
/// <summary>
/// If this review is External, which Provider did it come from
/// </summary>

View file

@ -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;
/// <summary>
/// Limit in bytes for allowing files to be added as attachments. Defaults to 25MB
/// </summary>
public int SizeLimit { get; set; } = 26_214_400;
/// <summary>
/// Should Kavita use config/templates for Email templates or the default ones
/// </summary>
public bool CustomizedTemplates { get; set; } = false;
}

View file

@ -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!;
/// <summary>
/// Logging level for server. Managed in appsettings.json.
/// </summary>
public string LoggingLevel { get; set; } = default!;
public string TaskBackup { get; set; } = default!;
/// <summary>
/// Port the server listens on. Managed in appsettings.json.
/// </summary>
@ -38,11 +39,6 @@ public class ServerSettingDto
/// </summary>
/// <remarks>If null or empty string, will default back to default install setting aka <see cref="DirectoryService.BookmarkDirectory"/></remarks>
public string BookmarksDirectory { get; set; } = default!;
/// <summary>
/// Email service to use for the invite user flow, forgot password, etc.
/// </summary>
/// <remarks>If null or empty string, will default back to default install setting aka <see cref="EmailService.DefaultApiUrl"/></remarks>
public string EmailServiceUrl { get; set; } = default!;
public string InstallVersion { get; set; } = default!;
/// <summary>
/// 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
/// </summary>
public CoverImageSize CoverImageSize { get; set; }
/// <summary>
/// SMTP Configuration
/// </summary>
public SmtpConfigDto SmtpConfig { get; set; }
/// <summary>
/// Are at least some basics filled in
/// </summary>
/// <returns></returns>
public bool IsEmailSetup()
{
return !string.IsNullOrEmpty(SmtpConfig.Host)
&& !string.IsNullOrEmpty(SmtpConfig.UserName)
&& !string.IsNullOrEmpty(HostName);
}
/// <summary>
/// Are at least some basics filled in, but not hostname as not required for Send to Device
/// </summary>
/// <returns></returns>
public bool IsEmailSetupForSendToDevice()
{
return !string.IsNullOrEmpty(SmtpConfig.Host)
&& !string.IsNullOrEmpty(SmtpConfig.UserName);
}
}

View file

@ -38,4 +38,16 @@ public class UpdateNotificationDto
/// Date of the publish
/// </summary>
public required string PublishDate { get; init; }
/// <summary>
/// Is the server on a nightly within this release
/// </summary>
public bool IsOnNightlyInRelease { get; set; }
/// <summary>
/// Is the server on an older version
/// </summary>
public bool IsReleaseNewer { get; set; }
/// <summary>
/// Is the server on this version
/// </summary>
public bool IsReleaseEqual { get; set; }
}

View file

@ -9,11 +9,17 @@ namespace API.DTOs;
public class VolumeDto : IHasReadTimeEstimate
{
public int Id { get; set; }
/// <inheritdoc cref="Volume.Number"/>
public int Number { get; set; }
/// <inheritdoc cref="Volume.MinNumber"/>
public float MinNumber { get; set; }
/// <inheritdoc cref="Volume.MaxNumber"/>
public float MaxNumber { get; set; }
/// <inheritdoc cref="Volume.Name"/>
public string Name { get; set; } = default!;
/// <summary>
/// This will map to MinNumber. Number was removed in v0.7.13.8/v0.7.14
/// </summary>
[Obsolete("Use MinNumber")]
public float Number { get; set; }
public int Pages { get; set; }
public int PagesRead { get; set; }
public DateTime LastModifiedUtc { get; set; }

View file

@ -58,6 +58,12 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<AppUserDashboardStream> AppUserDashboardStream { get; set; } = null!;
public DbSet<AppUserSideNavStream> AppUserSideNavStream { get; set; } = null!;
public DbSet<AppUserExternalSource> AppUserExternalSource { get; set; } = null!;
public DbSet<ExternalReview> ExternalReview { get; set; } = null!;
public DbSet<ExternalRating> ExternalRating { get; set; } = null!;
public DbSet<ExternalSeriesMetadata> ExternalSeriesMetadata { get; set; } = null!;
public DbSet<ExternalRecommendation> ExternalRecommendation { get; set; } = null!;
public DbSet<ManualMigrationHistory> ManualMigrationHistory { get; set; } = null!;
public DbSet<SeriesBlacklist> SeriesBlacklist { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
@ -137,9 +143,15 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
builder.Entity<AppUserSideNavStream>()
.HasIndex(e => e.Visible)
.IsUnique(false);
builder.Entity<ExternalSeriesMetadata>()
.HasOne(em => em.Series)
.WithOne(s => s.ExternalSeriesMetadata)
.HasForeignKey<ExternalSeriesMetadata>(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<AppUser, AppRole, int,
entity.LastModified = DateTime.Now;
entity.LastModifiedUtc = DateTime.UtcNow;
}
#nullable disable
private void OnSaveChanges()
{

View file

@ -0,0 +1,42 @@
using System;
using System.Threading.Tasks;
using API.Entities;
using Kavita.Common.EnvironmentInfo;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// For the v0.7.14 release, one of the nightlies had bad data that would cause issues. This drops those records
/// </summary>
public static class MigrateClearNightlyExternalSeriesRecords
{
public static async Task Migrate(DataContext dataContext, ILogger<Program> 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");
}
}

View file

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

View file

@ -15,9 +15,20 @@ public static class MigrateLibrariesToHaveAllFileTypes
{
public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger<Program> 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");
}
}

View file

@ -0,0 +1,86 @@
using System;
using System.Threading.Tasks;
using API.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// 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
/// </summary>
public static class MigrateManualHistory
{
public static async Task Migrate(DataContext dataContext, ILogger<Program> 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");
}
}

View file

@ -14,9 +14,9 @@ public static class MigrateUserLibrarySideNavStream
{
public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger<Program> 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)
{

View file

@ -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;
/// <summary>
/// Introduced in v0.7.14, this migrates the existing Volume Name -> Volume Min/Max Number
/// </summary>
public static class MigrateVolumeNumber
{
public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger<Program> 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");
}
}

View file

@ -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;
/// <summary>
/// v0.7.13.12/v0.7.14 - Want to read is extracted and saved in a csv
/// </summary>
/// <remarks>This must run BEFORE any DB migrations</remarks>
public static class MigrateWantToReadExport
{
public static async Task Migrate(DataContext dataContext, IDirectoryService directoryService, ILogger<Program> 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
}
}
}

View file

@ -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;
/// <summary>
/// v0.7.13.12/v0.7.14 - Want to read is imported from a csv
/// </summary>
public static class MigrateWantToReadImport
{
public static async Task Migrate(IUnitOfWork unitOfWork, IDirectoryService directoryService, ILogger<Program> 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<int>("AppUserId");
var seriesId = csvReader.GetField<int>("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");
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,227 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class ExternalSeriesMetadata : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ExternalRating",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
AverageScore = table.Column<int>(type: "INTEGER", nullable: false),
FavoriteCount = table.Column<int>(type: "INTEGER", nullable: false),
Provider = table.Column<int>(type: "INTEGER", nullable: false),
ProviderUrl = table.Column<string>(type: "TEXT", nullable: true),
SeriesId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExternalRating", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ExternalRecommendation",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true),
CoverUrl = table.Column<string>(type: "TEXT", nullable: true),
Url = table.Column<string>(type: "TEXT", nullable: true),
Summary = table.Column<string>(type: "TEXT", nullable: true),
AniListId = table.Column<int>(type: "INTEGER", nullable: true),
MalId = table.Column<long>(type: "INTEGER", nullable: true),
Provider = table.Column<int>(type: "INTEGER", nullable: false),
SeriesId = table.Column<int>(type: "INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ExternalRecommendation", x => x.Id);
table.ForeignKey(
name: "FK_ExternalRecommendation_Series_SeriesId",
column: x => x.SeriesId,
principalTable: "Series",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "ExternalReview",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Tagline = table.Column<string>(type: "TEXT", nullable: true),
Body = table.Column<string>(type: "TEXT", nullable: true),
BodyJustText = table.Column<string>(type: "TEXT", nullable: true),
RawBody = table.Column<string>(type: "TEXT", nullable: true),
Provider = table.Column<int>(type: "INTEGER", nullable: false),
SiteUrl = table.Column<string>(type: "TEXT", nullable: true),
Username = table.Column<string>(type: "TEXT", nullable: true),
Rating = table.Column<int>(type: "INTEGER", nullable: false),
Score = table.Column<int>(type: "INTEGER", nullable: false),
TotalVotes = table.Column<int>(type: "INTEGER", nullable: false),
SeriesId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExternalReview", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ExternalSeriesMetadata",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
AverageExternalRating = table.Column<int>(type: "INTEGER", nullable: false),
AniListId = table.Column<int>(type: "INTEGER", nullable: false),
MalId = table.Column<long>(type: "INTEGER", nullable: false),
GoogleBooksId = table.Column<string>(type: "TEXT", nullable: true),
LastUpdatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
SeriesId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExternalSeriesMetadata", x => x.Id);
table.ForeignKey(
name: "FK_ExternalSeriesMetadata_Series_SeriesId",
column: x => x.SeriesId,
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ExternalRatingExternalSeriesMetadata",
columns: table => new
{
ExternalRatingsId = table.Column<int>(type: "INTEGER", nullable: false),
ExternalSeriesMetadatasId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExternalRatingExternalSeriesMetadata", x => new { x.ExternalRatingsId, x.ExternalSeriesMetadatasId });
table.ForeignKey(
name: "FK_ExternalRatingExternalSeriesMetadata_ExternalRating_ExternalRatingsId",
column: x => x.ExternalRatingsId,
principalTable: "ExternalRating",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExternalRatingExternalSeriesMetadata_ExternalSeriesMetadata_ExternalSeriesMetadatasId",
column: x => x.ExternalSeriesMetadatasId,
principalTable: "ExternalSeriesMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ExternalRecommendationExternalSeriesMetadata",
columns: table => new
{
ExternalRecommendationsId = table.Column<int>(type: "INTEGER", nullable: false),
ExternalSeriesMetadatasId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExternalRecommendationExternalSeriesMetadata", x => new { x.ExternalRecommendationsId, x.ExternalSeriesMetadatasId });
table.ForeignKey(
name: "FK_ExternalRecommendationExternalSeriesMetadata_ExternalRecommendation_ExternalRecommendationsId",
column: x => x.ExternalRecommendationsId,
principalTable: "ExternalRecommendation",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExternalRecommendationExternalSeriesMetadata_ExternalSeriesMetadata_ExternalSeriesMetadatasId",
column: x => x.ExternalSeriesMetadatasId,
principalTable: "ExternalSeriesMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ExternalReviewExternalSeriesMetadata",
columns: table => new
{
ExternalReviewsId = table.Column<int>(type: "INTEGER", nullable: false),
ExternalSeriesMetadatasId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExternalReviewExternalSeriesMetadata", x => new { x.ExternalReviewsId, x.ExternalSeriesMetadatasId });
table.ForeignKey(
name: "FK_ExternalReviewExternalSeriesMetadata_ExternalReview_ExternalReviewsId",
column: x => x.ExternalReviewsId,
principalTable: "ExternalReview",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExternalReviewExternalSeriesMetadata_ExternalSeriesMetadata_ExternalSeriesMetadatasId",
column: x => x.ExternalSeriesMetadatasId,
principalTable: "ExternalSeriesMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ExternalRatingExternalSeriesMetadata_ExternalSeriesMetadatasId",
table: "ExternalRatingExternalSeriesMetadata",
column: "ExternalSeriesMetadatasId");
migrationBuilder.CreateIndex(
name: "IX_ExternalRecommendation_SeriesId",
table: "ExternalRecommendation",
column: "SeriesId");
migrationBuilder.CreateIndex(
name: "IX_ExternalRecommendationExternalSeriesMetadata_ExternalSeriesMetadatasId",
table: "ExternalRecommendationExternalSeriesMetadata",
column: "ExternalSeriesMetadatasId");
migrationBuilder.CreateIndex(
name: "IX_ExternalReviewExternalSeriesMetadata_ExternalSeriesMetadatasId",
table: "ExternalReviewExternalSeriesMetadata",
column: "ExternalSeriesMetadatasId");
migrationBuilder.CreateIndex(
name: "IX_ExternalSeriesMetadata_SeriesId",
table: "ExternalSeriesMetadata",
column: "SeriesId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExternalRatingExternalSeriesMetadata");
migrationBuilder.DropTable(
name: "ExternalRecommendationExternalSeriesMetadata");
migrationBuilder.DropTable(
name: "ExternalReviewExternalSeriesMetadata");
migrationBuilder.DropTable(
name: "ExternalRating");
migrationBuilder.DropTable(
name: "ExternalRecommendation");
migrationBuilder.DropTable(
name: "ExternalReview");
migrationBuilder.DropTable(
name: "ExternalSeriesMetadata");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class VolumeMinMaxNumbers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<float>(
name: "MaxNumber",
table: "Volume",
type: "REAL",
nullable: false,
defaultValue: 0f);
migrationBuilder.AddColumn<float>(
name: "MinNumber",
table: "Volume",
type: "REAL",
nullable: false,
defaultValue: 0f);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MaxNumber",
table: "Volume");
migrationBuilder.DropColumn(
name: "MinNumber",
table: "Volume");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,106 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class WantToReadFix : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Series_AspNetUsers_AppUserId",
table: "Series");
migrationBuilder.DropIndex(
name: "IX_Series_AppUserId",
table: "Series");
migrationBuilder.DropColumn(
name: "AppUserId",
table: "Series");
migrationBuilder.CreateTable(
name: "AppUserWantToRead",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
SeriesId = table.Column<int>(type: "INTEGER", nullable: false),
AppUserId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppUserWantToRead", x => x.Id);
table.ForeignKey(
name: "FK_AppUserWantToRead_AspNetUsers_AppUserId",
column: x => x.AppUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AppUserWantToRead_Series_SeriesId",
column: x => x.SeriesId,
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ManualMigrationHistory",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ProductVersion = table.Column<string>(type: "TEXT", nullable: true),
Name = table.Column<string>(type: "TEXT", nullable: true),
RanAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ManualMigrationHistory", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_AppUserWantToRead_AppUserId",
table: "AppUserWantToRead",
column: "AppUserId");
migrationBuilder.CreateIndex(
name: "IX_AppUserWantToRead_SeriesId",
table: "AppUserWantToRead",
column: "SeriesId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppUserWantToRead");
migrationBuilder.DropTable(
name: "ManualMigrationHistory");
migrationBuilder.AddColumn<int>(
name: "AppUserId",
table: "Series",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Series_AppUserId",
table: "Series",
column: "AppUserId");
migrationBuilder.AddForeignKey(
name: "FK_Series_AspNetUsers_AppUserId",
table: "Series",
column: "AppUserId",
principalTable: "AspNetUsers",
principalColumn: "Id");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,57 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class BlackListSeries : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "LastUpdatedUtc",
table: "ExternalSeriesMetadata",
newName: "ValidUntilUtc");
migrationBuilder.CreateTable(
name: "SeriesBlacklist",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
SeriesId = table.Column<int>(type: "INTEGER", nullable: false),
LastChecked = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SeriesBlacklist", x => x.Id);
table.ForeignKey(
name: "FK_SeriesBlacklist_Series_SeriesId",
column: x => x.SeriesId,
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_SeriesBlacklist_SeriesId",
table: "SeriesBlacklist",
column: "SeriesId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SeriesBlacklist");
migrationBuilder.RenameColumn(
name: "ValidUntilUtc",
table: "ExternalSeriesMetadata",
newName: "LastUpdatedUtc");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class ScrobbleEventError : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<float>(
name: "VolumeNumber",
table: "ScrobbleEvent",
type: "REAL",
nullable: true,
oldClrType: typeof(int),
oldType: "INTEGER",
oldNullable: true);
migrationBuilder.AddColumn<string>(
name: "ErrorDetails",
table: "ScrobbleEvent",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsErrored",
table: "ScrobbleEvent",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ErrorDetails",
table: "ScrobbleEvent");
migrationBuilder.DropColumn(
name: "IsErrored",
table: "ScrobbleEvent");
migrationBuilder.AlterColumn<int>(
name: "VolumeNumber",
table: "ScrobbleEvent",
type: "INTEGER",
nullable: true,
oldClrType: typeof(float),
oldType: "REAL",
oldNullable: true);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class DBTweaks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ExternalRecommendation_Series_SeriesId",
table: "ExternalRecommendation");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddForeignKey(
name: "FK_ExternalRecommendation_Series_SeriesId",
table: "ExternalRecommendation",
column: "SeriesId",
principalTable: "Series",
principalColumn: "Id");
}
}
}

View file

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.13");
modelBuilder.HasAnnotation("ProductVersion", "8.0.1");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -602,6 +602,27 @@ namespace API.Data.Migrations
b.ToTable("AppUserTableOfContent");
});
modelBuilder.Entity("API.Entities.AppUserWantToRead", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.HasIndex("SeriesId");
b.ToTable("AppUserWantToRead");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Property<int>("Id")
@ -980,6 +1001,26 @@ namespace API.Data.Migrations
b.ToTable("MangaFile");
});
modelBuilder.Entity("API.Entities.ManualMigrationHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("ProductVersion")
.HasColumnType("TEXT");
b.Property<DateTime>("RanAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("ManualMigrationHistory");
});
modelBuilder.Entity("API.Entities.MediaError", b =>
{
b.Property<int>("Id")
@ -1015,6 +1056,164 @@ namespace API.Data.Migrations
b.ToTable("MediaError");
});
modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AverageScore")
.HasColumnType("INTEGER");
b.Property<int>("FavoriteCount")
.HasColumnType("INTEGER");
b.Property<int>("Provider")
.HasColumnType("INTEGER");
b.Property<string>("ProviderUrl")
.HasColumnType("TEXT");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("ExternalRating");
});
modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("AniListId")
.HasColumnType("INTEGER");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<long?>("MalId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Provider")
.HasColumnType("INTEGER");
b.Property<int?>("SeriesId")
.HasColumnType("INTEGER");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<string>("Url")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SeriesId");
b.ToTable("ExternalRecommendation");
});
modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.HasColumnType("TEXT");
b.Property<string>("BodyJustText")
.HasColumnType("TEXT");
b.Property<int>("Provider")
.HasColumnType("INTEGER");
b.Property<int>("Rating")
.HasColumnType("INTEGER");
b.Property<string>("RawBody")
.HasColumnType("TEXT");
b.Property<int>("Score")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<string>("SiteUrl")
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasColumnType("TEXT");
b.Property<int>("TotalVotes")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("ExternalReview");
});
modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AniListId")
.HasColumnType("INTEGER");
b.Property<int>("AverageExternalRating")
.HasColumnType("INTEGER");
b.Property<string>("GoogleBooksId")
.HasColumnType("TEXT");
b.Property<long>("MalId")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<DateTime>("ValidUntilUtc")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SeriesId")
.IsUnique();
b.ToTable("ExternalSeriesMetadata");
});
modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("LastChecked")
.HasColumnType("TEXT");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId");
b.ToTable("SeriesBlacklist");
});
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
{
b.Property<int>("Id")
@ -1323,9 +1522,15 @@ namespace API.Data.Migrations
b.Property<DateTime>("CreatedUtc")
.HasColumnType("TEXT");
b.Property<string>("ErrorDetails")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<bool>("IsErrored")
.HasColumnType("INTEGER");
b.Property<bool>("IsProcessed")
.HasColumnType("INTEGER");
@ -1359,8 +1564,8 @@ namespace API.Data.Migrations
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int?>("VolumeNumber")
.HasColumnType("INTEGER");
b.Property<float?>("VolumeNumber")
.HasColumnType("REAL");
b.HasKey("Id");
@ -1412,9 +1617,6 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("AvgHoursToRead")
.HasColumnType("INTEGER");
@ -1495,8 +1697,6 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.HasIndex("AppUserId");
b.HasIndex("LibraryId");
b.ToTable("Series");
@ -1642,9 +1842,15 @@ namespace API.Data.Migrations
b.Property<int>("MaxHoursToRead")
.HasColumnType("INTEGER");
b.Property<float>("MaxNumber")
.HasColumnType("REAL");
b.Property<int>("MinHoursToRead")
.HasColumnType("INTEGER");
b.Property<float>("MinNumber")
.HasColumnType("REAL");
b.Property<string>("Name")
.HasColumnType("TEXT");
@ -1742,6 +1948,51 @@ namespace API.Data.Migrations
b.ToTable("CollectionTagSeriesMetadata");
});
modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b =>
{
b.Property<int>("ExternalRatingsId")
.HasColumnType("INTEGER");
b.Property<int>("ExternalSeriesMetadatasId")
.HasColumnType("INTEGER");
b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId");
b.HasIndex("ExternalSeriesMetadatasId");
b.ToTable("ExternalRatingExternalSeriesMetadata");
});
modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b =>
{
b.Property<int>("ExternalRecommendationsId")
.HasColumnType("INTEGER");
b.Property<int>("ExternalSeriesMetadatasId")
.HasColumnType("INTEGER");
b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId");
b.HasIndex("ExternalSeriesMetadatasId");
b.ToTable("ExternalRecommendationExternalSeriesMetadata");
});
modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b =>
{
b.Property<int>("ExternalReviewsId")
.HasColumnType("INTEGER");
b.Property<int>("ExternalSeriesMetadatasId")
.HasColumnType("INTEGER");
b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId");
b.HasIndex("ExternalSeriesMetadatasId");
b.ToTable("ExternalReviewExternalSeriesMetadata");
});
modelBuilder.Entity("GenreSeriesMetadata", b =>
{
b.Property<int>("GenresId")
@ -2062,6 +2313,25 @@ namespace API.Data.Migrations
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.AppUserWantToRead", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("WantToRead")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Series", "Series")
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.HasOne("API.Entities.Volume", "Volume")
@ -2128,6 +2398,28 @@ namespace API.Data.Migrations
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b =>
{
b.HasOne("API.Entities.Series", "Series")
.WithOne("ExternalSeriesMetadata")
.HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b =>
{
b.HasOne("API.Entities.Series", "Series")
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
{
b.HasOne("API.Entities.Series", "Series")
@ -2269,10 +2561,6 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.Series", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany("WantToRead")
.HasForeignKey("AppUserId");
b.HasOne("API.Entities.Library", "Library")
.WithMany("Series")
.HasForeignKey("LibraryId")
@ -2368,6 +2656,51 @@ namespace API.Data.Migrations
.IsRequired();
});
modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b =>
{
b.HasOne("API.Entities.Metadata.ExternalRating", null)
.WithMany()
.HasForeignKey("ExternalRatingsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null)
.WithMany()
.HasForeignKey("ExternalSeriesMetadatasId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b =>
{
b.HasOne("API.Entities.Metadata.ExternalRecommendation", null)
.WithMany()
.HasForeignKey("ExternalRecommendationsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null)
.WithMany()
.HasForeignKey("ExternalSeriesMetadatasId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b =>
{
b.HasOne("API.Entities.Metadata.ExternalReview", null)
.WithMany()
.HasForeignKey("ExternalReviewsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null)
.WithMany()
.HasForeignKey("ExternalSeriesMetadatasId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("GenreSeriesMetadata", b =>
{
b.HasOne("API.Entities.Genre", null)
@ -2510,6 +2843,8 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Navigation("ExternalSeriesMetadata");
b.Navigation("Metadata");
b.Navigation("Progress");

View file

@ -18,6 +18,6 @@ public class RecentlyAddedSeries
public string? ChapterRange { get; init; }
public string? ChapterTitle { get; init; }
public bool IsSpecial { get; init; }
public int VolumeNumber { get; init; }
public float VolumeNumber { get; init; }
public AgeRating AgeRating { get; init; }
}

View file

@ -32,7 +32,7 @@ public interface IAppUserProgressRepository
Task<ProgressDto?> GetUserProgressDtoAsync(int chapterId, int userId);
Task<bool> AnyUserProgressForSeriesAsync(int seriesId, int userId);
Task<int> GetHighestFullyReadChapterForSeries(int seriesId, int userId);
Task<int> GetHighestFullyReadVolumeForSeries(int seriesId, int userId);
Task<float> GetHighestFullyReadVolumeForSeries(int seriesId, int userId);
Task<DateTime?> GetLatestProgressForSeries(int seriesId, int userId);
Task<DateTime?> GetFirstProgressForSeries(int seriesId, int userId);
Task UpdateAllProgressThatAreMoreThanChapterPages();
@ -172,14 +172,14 @@ public class AppUserProgressRepository : IAppUserProgressRepository
return list.Count == 0 ? 0 : list.DefaultIfEmpty().Where(d => d != null).Max(d => (int) Math.Floor(Parser.MaxNumberFromRange(d)));
}
public async Task<int> GetHighestFullyReadVolumeForSeries(int seriesId, int userId)
public async Task<float> GetHighestFullyReadVolumeForSeries(int seriesId, int userId)
{
var list = await _context.AppUserProgresses
.Join(_context.Chapter, appUserProgresses => appUserProgresses.ChapterId, chapter => chapter.Id,
(appUserProgresses, chapter) => new {appUserProgresses, chapter})
.Where(p => p.appUserProgresses.SeriesId == seriesId && p.appUserProgresses.AppUserId == userId &&
p.appUserProgresses.PagesRead >= p.chapter.Pages)
.Select(p => p.chapter.Volume.Number)
.Select(p => p.chapter.Volume.MaxNumber)
.ToListAsync();
return list.Count == 0 ? 0 : list.DefaultIfEmpty().Max();
}

View file

@ -8,6 +8,7 @@ using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
#nullable enable
public interface IAppUserSmartFilterRepository
{
@ -55,6 +56,7 @@ public class AppUserSmartFilterRepository : IAppUserSmartFilterRepository
public async Task<AppUserSmartFilter?> GetById(int smartFilterId)
{
return await _context.AppUserSmartFilter.FirstOrDefaultAsync(d => d.Id == smartFilterId);
return await _context.AppUserSmartFilter
.FirstOrDefaultAsync(d => d.Id == smartFilterId);
}
}

View file

@ -14,6 +14,7 @@ using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
#nullable enable
[Flags]
public enum ChapterIncludes

View file

@ -0,0 +1,255 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.DTOs;
using API.DTOs.Recommendation;
using API.DTOs.Scrobbling;
using API.DTOs.SeriesDetail;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Extensions.QueryExtensions;
using API.Services.Plus;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
#nullable enable
public interface IExternalSeriesMetadataRepository
{
void Attach(ExternalSeriesMetadata metadata);
void Attach(ExternalRating rating);
void Attach(ExternalReview review);
void Remove(IEnumerable<ExternalReview>? reviews);
void Remove(IEnumerable<ExternalRating>? ratings);
void Remove(IEnumerable<ExternalRecommendation>? recommendations);
void Remove(ExternalSeriesMetadata metadata);
Task<ExternalSeriesMetadata?> GetExternalSeriesMetadata(int seriesId);
Task<bool> ExternalSeriesMetadataNeedsRefresh(int seriesId);
Task<SeriesDetailPlusDto> GetSeriesDetailPlusDto(int seriesId);
Task LinkRecommendationsToSeries(Series series);
Task<bool> IsBlacklistedSeries(int seriesId);
Task CreateBlacklistedSeries(int seriesId, bool saveChanges = true);
Task RemoveFromBlacklist(int seriesId);
Task<IList<int>> GetAllSeriesIdsWithoutMetadata(int limit);
}
public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepository
{
private readonly DataContext _context;
private readonly IMapper _mapper;
public ExternalSeriesMetadataRepository(DataContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
public void Attach(ExternalSeriesMetadata metadata)
{
_context.ExternalSeriesMetadata.Attach(metadata);
}
public void Attach(ExternalRating rating)
{
_context.ExternalRating.Attach(rating);
}
public void Attach(ExternalReview review)
{
_context.ExternalReview.Attach(review);
}
public void Remove(IEnumerable<ExternalReview>? reviews)
{
if (reviews == null) return;
_context.ExternalReview.RemoveRange(reviews);
}
public void Remove(IEnumerable<ExternalRating>? ratings)
{
if (ratings == null) return;
_context.ExternalRating.RemoveRange(ratings);
}
public void Remove(IEnumerable<ExternalRecommendation>? recommendations)
{
if (recommendations == null) return;
_context.ExternalRecommendation.RemoveRange(recommendations);
}
public void Remove(ExternalSeriesMetadata? metadata)
{
if (metadata == null) return;
_context.ExternalSeriesMetadata.Remove(metadata);
}
/// <summary>
/// Returns the ExternalSeriesMetadata entity for the given Series including all linked tables
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
public Task<ExternalSeriesMetadata?> GetExternalSeriesMetadata(int seriesId)
{
return _context.ExternalSeriesMetadata
.Where(s => s.SeriesId == seriesId)
.Include(s => s.ExternalReviews)
.Include(s => s.ExternalRatings.OrderBy(r => r.AverageScore))
.Include(s => s.ExternalRecommendations.OrderBy(r => r.Id))
.AsSplitQuery()
.FirstOrDefaultAsync();
}
public async Task<bool> ExternalSeriesMetadataNeedsRefresh(int seriesId)
{
var row = await _context.ExternalSeriesMetadata
.Where(s => s.SeriesId == seriesId)
.FirstOrDefaultAsync();
return row == null || row.ValidUntilUtc <= DateTime.UtcNow;
}
public async Task<SeriesDetailPlusDto> GetSeriesDetailPlusDto(int seriesId)
{
var seriesDetailDto = await _context.ExternalSeriesMetadata
.Where(m => m.SeriesId == seriesId)
.Include(m => m.ExternalRatings)
.Include(m => m.ExternalReviews)
.Include(m => m.ExternalRecommendations)
.FirstOrDefaultAsync();
if (seriesDetailDto == null)
{
return null; // or handle the case when seriesDetailDto is not found
}
var externalSeriesRecommendations = seriesDetailDto.ExternalRecommendations
.Where(r => r.SeriesId == null)
.Select(r => _mapper.Map<ExternalSeriesDto>(r))
.ToList();
var ownedIds = seriesDetailDto.ExternalRecommendations
.Where(r => r.SeriesId != null)
.Select(r => r.SeriesId)
.ToList();
var ownedSeriesRecommendations = await _context.Series
.Where(s => ownedIds.Contains(s.Id))
.OrderBy(s => s.SortName.ToLower())
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.ToListAsync();
IEnumerable<UserReviewDto> reviews = new List<UserReviewDto>();
if (seriesDetailDto.ExternalReviews != null && seriesDetailDto.ExternalReviews.Any())
{
reviews = seriesDetailDto.ExternalReviews
.Select(r =>
{
var ret = _mapper.Map<UserReviewDto>(r);
ret.IsExternal = true;
return ret;
})
.OrderByDescending(r => r.Score);
}
IEnumerable<RatingDto> ratings = new List<RatingDto>();
if (seriesDetailDto.ExternalRatings != null && seriesDetailDto.ExternalRatings.Any())
{
ratings = seriesDetailDto.ExternalRatings
.Select(r => _mapper.Map<RatingDto>(r));
}
var seriesDetailPlusDto = new SeriesDetailPlusDto()
{
Ratings = ratings,
Reviews = reviews,
Recommendations = new RecommendationDto()
{
ExternalSeries = externalSeriesRecommendations,
OwnedSeries = ownedSeriesRecommendations
}
};
return seriesDetailPlusDto;
}
/// <summary>
/// Searches Recommendations without a SeriesId on record and attempts to link based on Series Name/Localized Name
/// </summary>
/// <param name="series"></param>
/// <returns></returns>
public async Task LinkRecommendationsToSeries(Series series)
{
var recMatches = await _context.ExternalRecommendation
.Where(r => r.SeriesId == null || r.SeriesId == 0)
.Where(r => EF.Functions.Like(r.Name, series.Name) ||
EF.Functions.Like(r.Name, series.LocalizedName))
.ToListAsync();
foreach (var rec in recMatches)
{
rec.SeriesId = series.Id;
}
await _context.SaveChangesAsync();
}
public Task<bool> IsBlacklistedSeries(int seriesId)
{
return _context.SeriesBlacklist.AnyAsync(s => s.SeriesId == seriesId);
}
/// <summary>
/// Creates a new instance against SeriesId and Saves to the DB
/// </summary>
/// <param name="seriesId"></param>
/// <param name="saveChanges"></param>
public async Task CreateBlacklistedSeries(int seriesId, bool saveChanges = true)
{
if (seriesId <= 0 || await _context.SeriesBlacklist.AnyAsync(s => s.SeriesId == seriesId)) return;
await _context.SeriesBlacklist.AddAsync(new SeriesBlacklist()
{
SeriesId = seriesId
});
if (saveChanges)
{
await _context.SaveChangesAsync();
}
}
/// <summary>
/// Removes the Series from Blacklist and Saves to the DB
/// </summary>
/// <param name="seriesId"></param>
public async Task RemoveFromBlacklist(int seriesId)
{
var seriesBlacklist = await _context.SeriesBlacklist.FirstOrDefaultAsync(sb => sb.SeriesId == seriesId);
if (seriesBlacklist != null)
{
// Remove the SeriesBlacklist entity from the context
_context.SeriesBlacklist.Remove(seriesBlacklist);
// Save the changes to the database
await _context.SaveChangesAsync();
}
}
public async Task<IList<int>> GetAllSeriesIdsWithoutMetadata(int limit)
{
return await _context.Series
.Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type))
.Where(s => s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc < DateTime.UtcNow)
.OrderByDescending(s => s.Library.Type)
.ThenBy(s => s.NormalizedName)
.Select(s => s.Id)
.Take(limit)
.ToListAsync();
}
}

View file

@ -43,6 +43,7 @@ public interface ILibraryRepository
Task<IEnumerable<Library>> GetLibrariesForUserIdAsync(int userId);
IEnumerable<int> GetLibraryIdsForUserIdAsync(int userId, QueryContext queryContext = QueryContext.None);
Task<LibraryType> GetLibraryTypeAsync(int libraryId);
Task<LibraryType> GetLibraryTypeBySeriesIdAsync(int seriesId);
Task<IEnumerable<Library>> GetLibraryForIdsAsync(IEnumerable<int> libraryIds, LibraryIncludes includes = LibraryIncludes.None);
Task<int> GetTotalFiles();
IEnumerable<JumpKeyDto> GetJumpBarAsync(int libraryId);
@ -54,6 +55,8 @@ public interface ILibraryRepository
Task<IList<string>> GetAllCoverImagesAsync();
Task<IList<Library>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
Task<bool> GetAllowsScrobblingBySeriesId(int seriesId);
Task<IDictionary<int, LibraryType>> GetLibraryTypesBySeriesIdsAsync(IList<int> seriesIds);
}
public class LibraryRepository : ILibraryRepository
@ -106,6 +109,7 @@ public class LibraryRepository : ILibraryRepository
return await _context.Library
.Include(l => l.AppUsers)
.Includes(includes)
.AsSplitQuery()
.ToListAsync();
}
@ -141,6 +145,14 @@ public class LibraryRepository : ILibraryRepository
.FirstAsync();
}
public async Task<LibraryType> GetLibraryTypeBySeriesIdAsync(int seriesId)
{
return await _context.Series
.Where(s => s.Id == seriesId)
.Select(s => s.Library.Type)
.FirstAsync();
}
public async Task<IEnumerable<Library>> GetLibraryForIdsAsync(IEnumerable<int> libraryIds, LibraryIncludes includes = LibraryIncludes.None)
{
return await _context.Library
@ -341,4 +353,16 @@ public class LibraryRepository : ILibraryRepository
.Select(s => s.Library.AllowScrobbling)
.SingleOrDefaultAsync();
}
public async Task<IDictionary<int, LibraryType>> GetLibraryTypesBySeriesIdsAsync(IList<int> seriesIds)
{
return await _context.Series
.Where(series => seriesIds.Contains(series.Id))
.Select(series => new
{
series.Id,
series.Library.Type
})
.ToDictionaryAsync(entity => entity.Id, entity => entity.Type);
}
}

View file

@ -27,7 +27,7 @@ public interface IScrobbleRepository
Task ClearScrobbleErrors();
Task<bool> HasErrorForSeries(int seriesId);
Task<ScrobbleEvent?> GetEvent(int userId, int seriesId, ScrobbleEventType eventType);
Task<IEnumerable<ScrobbleEventDto>> GetUserEvents(int userId);
Task<IEnumerable<ScrobbleEvent>> GetUserEventsForSeries(int userId, int seriesId);
Task<PagedList<ScrobbleEventDto>> GetUserEvents(int userId, ScrobbleEventFilter filter, UserParams pagination);
}
@ -127,16 +127,17 @@ public class ScrobbleRepository : IScrobbleRepository
return await _context.ScrobbleEvent.FirstOrDefaultAsync(e =>
e.AppUserId == userId && e.SeriesId == seriesId && e.ScrobbleEventType == eventType);
}
public async Task<IEnumerable<ScrobbleEventDto>> GetUserEvents(int userId)
public async Task<IEnumerable<ScrobbleEvent>> GetUserEventsForSeries(int userId, int seriesId)
{
return await _context.ScrobbleEvent
.Where(e => e.AppUserId == userId)
.Where(e => e.AppUserId == userId && !e.IsProcessed)
.Include(e => e.Series)
.OrderBy(e => e.LastModifiedUtc)
.AsSplitQuery()
.ProjectTo<ScrobbleEventDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<PagedList<ScrobbleEventDto>> GetUserEvents(int userId, ScrobbleEventFilter filter, UserParams pagination)
{
var query = _context.ScrobbleEvent
@ -146,6 +147,7 @@ public class ScrobbleRepository : IScrobbleRepository
.WhereIf(!string.IsNullOrEmpty(filter.Query), s =>
EF.Functions.Like(s.Series.Name, $"%{filter.Query}%")
)
.WhereIf(!filter.IncludeReviews, e => e.ScrobbleEventType != ScrobbleEventType.Review)
.AsSplitQuery()
.ProjectTo<ScrobbleEventDto>(_mapper.ConfigurationProvider);

View file

@ -1,10 +1,9 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.Data.ManualMigrations;
using API.Constants;
using API.Data.Misc;
using API.Data.Scanner;
using API.DTOs;
@ -13,8 +12,8 @@ using API.DTOs.Dashboard;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Metadata;
using API.DTOs.Reader;
using API.DTOs.ReadingLists;
using API.DTOs.Scrobbling;
using API.DTOs.Search;
using API.DTOs.SeriesDetail;
using API.DTOs.Settings;
@ -27,12 +26,13 @@ using API.Extensions.QueryExtensions.Filtering;
using API.Helpers;
using API.Helpers.Converters;
using API.Services;
using API.Services.Plus;
using API.Services.Tasks;
using API.Services.Tasks.Scanner;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SQLite;
namespace API.Data.Repositories;
@ -45,7 +45,12 @@ public enum SeriesIncludes
Metadata = 4,
Related = 8,
Library = 16,
Chapters = 32
Chapters = 32,
ExternalReviews = 64,
ExternalRatings = 128,
ExternalRecommendations = 256,
ExternalMetadata = 512
}
/// <summary>
@ -89,6 +94,7 @@ public interface ISeriesRepository
Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId, SeriesIncludes includes = SeriesIncludes.None);
Task<SeriesDto?> GetSeriesDtoByIdAsync(int seriesId, int userId);
Task<Series?> GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata);
Task<IList<SeriesDto>> GetSeriesDtoByIdsAsync(IEnumerable<int> seriesIds, AppUser user);
Task<IList<Series>> GetSeriesByIdsAsync(IList<int> seriesIds);
Task<int[]> GetChapterIdsForSeriesAsync(IList<int> seriesIds);
Task<IDictionary<int, IList<int>>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds);
@ -131,6 +137,8 @@ public interface ISeriesRepository
Task<IEnumerable<Series>> GetAllSeriesByNameAsync(IList<string> normalizedNames,
int userId, SeriesIncludes includes = SeriesIncludes.None);
Task<Series?> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true);
public Task<IList<Series>> GetAllSeriesByAnyName(string seriesName, string localizedName, int libraryId,
MangaFormat format);
Task<IList<Series>> RemoveSeriesNotInList(IList<ParsedSeries> seenSeries, int libraryId);
Task<IDictionary<string, IList<SeriesModified>>> GetFolderPathMap(int libraryId);
Task<AgeRating?> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds);
@ -141,25 +149,28 @@ public interface ISeriesRepository
Task<IDictionary<int, int>> GetLibraryIdsForSeriesAsync();
Task<IList<SeriesMetadataDto>> GetSeriesMetadataForIds(IEnumerable<int> seriesIds);
Task<IList<Series>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat, bool customOnly = true);
Task<SeriesDto?> GetSeriesDtoByNamesAndMetadataIdsForUser(int userId, IEnumerable<string> names, LibraryType libraryType, string aniListUrl, string malUrl);
Task<SeriesDto?> GetSeriesDtoByNamesAndMetadataIds(IEnumerable<string> names, LibraryType libraryType, string aniListUrl, string malUrl);
Task<int> GetAverageUserRating(int seriesId, int userId);
Task RemoveFromOnDeck(int seriesId, int userId);
Task ClearOnDeckRemoval(int seriesId, int userId);
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto);
Task<PlusSeriesDto?> GetPlusSeriesDto(int seriesId);
}
public class SeriesRepository : ISeriesRepository
{
private readonly DataContext _context;
private readonly IMapper _mapper;
private readonly UserManager<AppUser> _userManager;
private readonly Regex _yearRegex = new Regex(@"\d{4}", RegexOptions.Compiled,
Services.Tasks.Scanner.Parser.Parser.RegexTimeout);
public SeriesRepository(DataContext context, IMapper mapper)
public SeriesRepository(DataContext context, IMapper mapper, UserManager<AppUser> userManager)
{
_context = context;
_mapper = mapper;
_userManager = userManager;
}
public void Add(Series series)
@ -172,6 +183,11 @@ public class SeriesRepository : ISeriesRepository
_context.Series.Attach(series);
}
public void Attach(ExternalSeriesMetadata metadata)
{
_context.ExternalSeriesMetadata.Attach(metadata);
}
public void Update(Series series)
{
_context.Entry(series).State = EntityState.Modified;
@ -350,8 +366,8 @@ public class SeriesRepository : ISeriesRepository
.Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%"))
.IsRestricted(QueryContext.Search)
.AsSplitQuery()
.Take(maxRecords)
.OrderBy(l => l.Name.ToLower())
.Take(maxRecords)
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -370,8 +386,8 @@ public class SeriesRepository : ISeriesRepository
.Include(s => s.Library)
.AsNoTracking()
.AsSplitQuery()
.Take(maxRecords)
.OrderBy(s => s.SortName!.ToLower())
.Take(maxRecords)
.ProjectTo<SearchResultDto>(_mapper.ConfigurationProvider)
.AsEnumerable();
@ -407,8 +423,8 @@ public class SeriesRepository : ISeriesRepository
.Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%"))
.RestrictAgainstAgeRestriction(userRating)
.AsSplitQuery()
.Take(maxRecords)
.OrderBy(r => r.NormalizedTitle)
.Take(maxRecords)
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -418,7 +434,6 @@ public class SeriesRepository : ISeriesRepository
.Where(c => c.Promoted || isAdmin)
.RestrictAgainstAgeRestriction(userRating)
.OrderBy(s => s.NormalizedTitle)
.AsNoTracking()
.AsSplitQuery()
.Take(maxRecords)
.OrderBy(c => c.NormalizedTitle)
@ -430,8 +445,8 @@ public class SeriesRepository : ISeriesRepository
.SelectMany(sm => sm.People.Where(t => t.Name != null && EF.Functions.Like(t.Name, $"%{searchQuery}%")))
.AsSplitQuery()
.Distinct()
.Take(maxRecords)
.OrderBy(p => p.NormalizedName)
.Take(maxRecords)
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -440,8 +455,8 @@ public class SeriesRepository : ISeriesRepository
.SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
.AsSplitQuery()
.Distinct()
.Take(maxRecords)
.OrderBy(t => t.NormalizedTitle)
.Take(maxRecords)
.ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -450,8 +465,8 @@ public class SeriesRepository : ISeriesRepository
.SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
.AsSplitQuery()
.Distinct()
.Take(maxRecords)
.OrderBy(t => t.NormalizedTitle)
.Take(maxRecords)
.ProjectTo<TagDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -462,14 +477,22 @@ public class SeriesRepository : ISeriesRepository
.SelectMany(v => v.Chapters)
.SelectMany(c => c.Files.Select(f => f.Id));
result.Files = await _context.MangaFile
.Where(m => EF.Functions.Like(m.FilePath, $"%{searchQuery}%") && fileIds.Contains(m.Id))
.AsSplitQuery()
.Take(maxRecords)
.OrderBy(f => f.FilePath)
.ProjectTo<MangaFileDto>(_mapper.ConfigurationProvider)
.ToListAsync();
// Need to check if an admin
var user = await _context.AppUser.FirstAsync(u => u.Id == userId);
if (await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole))
{
result.Files = await _context.MangaFile
.Where(m => EF.Functions.Like(m.FilePath, $"%{searchQuery}%") && fileIds.Contains(m.Id))
.AsSplitQuery()
.OrderBy(f => f.FilePath)
.Take(maxRecords)
.ProjectTo<MangaFileDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
else
{
result.Files = new List<MangaFileDto>();
}
result.Chapters = await _context.Chapter
.Include(c => c.Files)
@ -478,8 +501,8 @@ public class SeriesRepository : ISeriesRepository
)
.Where(c => c.Files.All(f => fileIds.Contains(f.Id)))
.AsSplitQuery()
.Take(maxRecords)
.OrderBy(c => c.TitleName)
.Take(maxRecords)
.ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -529,7 +552,7 @@ public class SeriesRepository : ISeriesRepository
}
/// <summary>
/// Returns Volumes, Metadata, and Collection Tags
/// Returns Full Series including all external links
/// </summary>
/// <param name="seriesIds"></param>
/// <returns></returns>
@ -537,14 +560,45 @@ public class SeriesRepository : ISeriesRepository
{
return await _context.Series
.Include(s => s.Volumes)
.Include(s => s.Relations)
.Include(s => s.Metadata)
.ThenInclude(m => m.CollectionTags)
.Include(s => s.Relations)
.Include(s => s.ExternalSeriesMetadata)
.Include(s => s.ExternalSeriesMetadata)
.ThenInclude(e => e.ExternalRatings)
.Include(s => s.ExternalSeriesMetadata)
.ThenInclude(e => e.ExternalReviews)
.Include(s => s.ExternalSeriesMetadata)
.ThenInclude(e => e.ExternalRecommendations)
.Where(s => seriesIds.Contains(s.Id))
.AsSplitQuery()
.ToListAsync();
}
public async Task<IList<SeriesDto>> GetSeriesDtoByIdsAsync(IEnumerable<int> seriesIds, AppUser user)
{
var allowedLibraries = await _context.Library
.Where(library => library.AppUsers.Any(x => x.Id == user.Id))
.Select(l => l.Id)
.ToListAsync();
var restriction = new AgeRestriction()
{
AgeRating = user.AgeRestriction,
IncludeUnknowns = user.AgeRestrictionIncludeUnknowns
};
return await _context.Series
.Include(s => s.Metadata)
.Where(s => seriesIds.Contains(s.Id) && allowedLibraries.Contains(s.LibraryId))
.RestrictAgainstAgeRestriction(restriction)
.AsSplitQuery()
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<int[]> GetChapterIdsForSeriesAsync(IList<int> seriesIds)
{
var volumes = await _context.Volume
@ -661,6 +715,29 @@ public class SeriesRepository : ISeriesRepository
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
}
public async Task<PlusSeriesDto?> GetPlusSeriesDto(int seriesId)
{
return await _context.Series
.Where(s => s.Id == seriesId)
.Select(series => new PlusSeriesDto()
{
MediaFormat = LibraryTypeHelper.GetFormat(series.Library.Type),
SeriesName = series.Name,
AltSeriesName = series.LocalizedName,
AniListId = ScrobblingService.ExtractId<int?>(series.Metadata.WebLinks,
ScrobblingService.AniListWeblinkWebsite),
MalId = ScrobblingService.ExtractId<long?>(series.Metadata.WebLinks,
ScrobblingService.MalWeblinkWebsite),
GoogleBooksId = ScrobblingService.ExtractId<string?>(series.Metadata.WebLinks,
ScrobblingService.GoogleBooksWeblinkWebsite),
MangaDexId = ScrobblingService.ExtractId<string?>(series.Metadata.WebLinks,
ScrobblingService.MangaDexWeblinkWebsite),
VolumeCount = series.Volumes.Count,
ChapterCount = series.Volumes.SelectMany(v => v.Chapters).Count(c => !c.IsSpecial),
Year = series.Metadata.ReleaseYear
})
.FirstOrDefaultAsync();
}
public async Task AddSeriesModifiers(int userId, IList<SeriesDto> series)
{
@ -951,6 +1028,8 @@ public class SeriesRepository : ISeriesRepository
SortField.TimeToRead => query.DoOrderBy(s => s.AvgHoursToRead, filter.SortOptions),
SortField.ReleaseYear => query.DoOrderBy(s => s.Metadata.ReleaseYear, filter.SortOptions),
SortField.ReadProgress => query.DoOrderBy(s => s.Progress.Where(p => p.SeriesId == s.Id).Select(p => p.LastModified).Max(), filter.SortOptions),
SortField.AverageRating => query.DoOrderBy(s => s.ExternalSeriesMetadata.ExternalRatings
.Where(p => p.SeriesId == s.Id).Average(p => p.AverageScore), filter.SortOptions),
_ => query
};
@ -1003,7 +1082,9 @@ public class SeriesRepository : ISeriesRepository
var wantToReadStmt = filter.Statements.FirstOrDefault(stmt => stmt.Field == FilterField.WantToRead);
if (wantToReadStmt == null) return query;
var seriesIds = _context.AppUser.Where(u => u.Id == userId).SelectMany(u => u.WantToRead).Select(s => s.Id);
var seriesIds = _context.AppUser.Where(u => u.Id == userId)
.SelectMany(u => u.WantToRead)
.Select(s => s.SeriesId);
if (bool.Parse(wantToReadStmt.Value))
{
query = query.Where(s => seriesIds.Contains(s.Id));
@ -1116,6 +1197,7 @@ public class SeriesRepository : ISeriesRepository
FilterField.ReleaseYear => query.HasReleaseYear(true, statement.Comparison, (int) value),
FilterField.ReadTime => query.HasAverageReadTime(true, statement.Comparison, (int) value),
FilterField.ReadingDate => query.HasReadingDate(true, statement.Comparison, (DateTime) value, userId),
FilterField.AverageRating => query.HasAverageRating(true, statement.Comparison, (float) value),
_ => throw new ArgumentOutOfRangeException()
};
}
@ -1223,8 +1305,10 @@ public class SeriesRepository : ISeriesRepository
.Where(library => library.AppUsers.Any(x => x.Id == userId))
.AsSplitQuery()
.Select(l => l.Id);
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
return await _context.Series
.RestrictAgainstAgeRestriction(userRating)
.Where(s => seriesIds.Contains(s.Id) && allowedLibraries.Contains(s.LibraryId))
.OrderBy(s => s.SortName.ToLower())
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
@ -1563,6 +1647,27 @@ public class SeriesRepository : ISeriesRepository
#nullable enable
}
public async Task<IList<Series>> GetAllSeriesByAnyName(string seriesName, string localizedName, int libraryId,
MangaFormat format)
{
var normalizedSeries = seriesName.ToNormalized();
var normalizedLocalized = localizedName.ToNormalized();
return await _context.Series
.Where(s => s.LibraryId == libraryId)
.Where(s => s.Format == format && format != MangaFormat.Unknown)
.Where(s =>
s.NormalizedName.Equals(normalizedSeries)
|| s.NormalizedName.Equals(normalizedLocalized)
|| s.NormalizedLocalizedName.Equals(normalizedSeries)
|| (!string.IsNullOrEmpty(normalizedLocalized) && s.NormalizedLocalizedName.Equals(normalizedLocalized))
|| (s.OriginalName != null && s.OriginalName.Equals(seriesName))
)
.AsSplitQuery()
.ToListAsync();
}
/// <summary>
/// Removes series that are not in the seenSeries list. Does not commit.
@ -1789,7 +1894,7 @@ public class SeriesRepository : ISeriesRepository
ChapterNumber = c.Number,
ChapterRange = c.Range,
IsSpecial = c.IsSpecial,
VolumeNumber = c.Volume.Number,
VolumeNumber = c.Volume.MinNumber,
ChapterTitle = c.Title,
AgeRating = c.Volume.Series.Metadata.AgeRating
})
@ -1805,7 +1910,8 @@ public class SeriesRepository : ISeriesRepository
var query = _context.AppUser
.Where(user => user.Id == userId)
.SelectMany(u => u.WantToRead)
.Where(s => libraryIds.Contains(s.LibraryId))
.Where(s => libraryIds.Contains(s.Series.LibraryId))
.Select(w => w.Series)
.AsSplitQuery()
.AsNoTracking();
@ -1820,7 +1926,8 @@ public class SeriesRepository : ISeriesRepository
var query = _context.AppUser
.Where(user => user.Id == userId)
.SelectMany(u => u.WantToRead)
.Where(s => libraryIds.Contains(s.LibraryId))
.Where(s => libraryIds.Contains(s.Series.LibraryId))
.Select(w => w.Series)
.AsSplitQuery()
.AsNoTracking();
@ -1835,28 +1942,33 @@ public class SeriesRepository : ISeriesRepository
return await _context.AppUser
.Where(user => user.Id == userId)
.SelectMany(u => u.WantToRead)
.Where(s => libraryIds.Contains(s.LibraryId))
.Where(s => libraryIds.Contains(s.Series.LibraryId))
.Select(w => w.Series)
.AsSplitQuery()
.AsNoTracking()
.ToListAsync();
}
/// <summary>
/// Uses multiple names to find a match against a series then ensures the user has appropriate access to it. If not, returns null.
/// Uses multiple names to find a match against a series. If not, returns null.
/// </summary>
/// <remarks>This does not restrict to the user at all. That is handled at the API level.</remarks>
/// <param name="userId"></param>
/// <param name="names"></param>
/// <returns></returns>
public async Task<SeriesDto?> GetSeriesDtoByNamesAndMetadataIdsForUser(int userId, IEnumerable<string> names, LibraryType libraryType, string aniListUrl, string malUrl)
public async Task<SeriesDto?> GetSeriesDtoByNamesAndMetadataIds(IEnumerable<string> names, LibraryType libraryType, string aniListUrl, string malUrl)
{
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
var libraryIds = await _context.Library.GetUserLibrariesByType(userId, libraryType).ToListAsync();
var libraryIds = await _context.Library
.Where(lib => lib.Type == libraryType)
.Select(l => l.Id)
.ToListAsync();
var normalizedNames = names.Select(n => n.ToNormalized()).ToList();
SeriesDto? result = null;
if (!string.IsNullOrEmpty(aniListUrl) || !string.IsNullOrEmpty(malUrl))
{
// TODO: I can likely work AniList and MalIds from ExternalSeriesMetadata in here
result = await _context.Series
.RestrictAgainstAgeRestriction(userRating)
.Where(s => !string.IsNullOrEmpty(s.Metadata.WebLinks))
.Where(s => libraryIds.Contains(s.Library.Id))
.WhereIf(!string.IsNullOrEmpty(aniListUrl), s => s.Metadata.WebLinks.Contains(aniListUrl))
@ -1869,7 +1981,6 @@ public class SeriesRepository : ISeriesRepository
if (result != null) return result;
return await _context.Series
.RestrictAgainstAgeRestriction(userRating)
.Where(s => normalizedNames.Contains(s.NormalizedName) ||
normalizedNames.Contains(s.NormalizedLocalizedName))
.Where(s => libraryIds.Contains(s.Library.Id))
@ -1886,7 +1997,8 @@ public class SeriesRepository : ISeriesRepository
{
// If there is 0 or 1 rating and that rating is you, return 0 back
var countOfRatingsThatAreUser = await _context.AppUserRating
.Where(r => r.SeriesId == seriesId && r.HasBeenRated).CountAsync(u => u.AppUserId == userId);
.Where(r => r.SeriesId == seriesId && r.HasBeenRated)
.CountAsync(u => u.AppUserId == userId);
if (countOfRatingsThatAreUser == 1)
{
return 0;
@ -1926,7 +2038,7 @@ public class SeriesRepository : ISeriesRepository
var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
return await _context.AppUser
.Where(user => user.Id == userId)
.SelectMany(u => u.WantToRead.Where(s => s.Id == seriesId && libraryIds.Contains(s.LibraryId)))
.SelectMany(u => u.WantToRead.Where(s => s.SeriesId == seriesId && libraryIds.Contains(s.Series.LibraryId)))
.AsSplitQuery()
.AsNoTracking()
.AnyAsync();

View file

@ -1,9 +1,11 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.SeriesDetail;
using API.DTOs.Settings;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using AutoMapper;
using Microsoft.EntityFrameworkCore;
@ -16,6 +18,7 @@ public interface ISettingsRepository
Task<ServerSetting> GetSettingAsync(ServerSettingKey key);
Task<IEnumerable<ServerSetting>> GetSettingsAsync();
void Remove(ServerSetting setting);
Task<ExternalSeriesMetadata?> GetExternalSeriesMetadata(int seriesId);
}
public class SettingsRepository : ISettingsRepository
{
@ -38,6 +41,13 @@ public class SettingsRepository : ISettingsRepository
_context.Remove(setting);
}
public async Task<ExternalSeriesMetadata?> GetExternalSeriesMetadata(int seriesId)
{
return await _context.ExternalSeriesMetadata
.Where(s => s.SeriesId == seriesId)
.FirstOrDefaultAsync();
}
public async Task<ServerSettingDto> GetSettingsDtoAsync()
{
var settings = await _context.ServerSetting

View file

@ -152,7 +152,7 @@ public class VolumeRepository : IVolumeRepository
.Include(vol => vol.Chapters)
.ThenInclude(c => c.Files)
.AsSplitQuery()
.OrderBy(vol => vol.Number)
.OrderBy(vol => vol.MinNumber)
.ToListAsync();
}
@ -185,7 +185,7 @@ public class VolumeRepository : IVolumeRepository
.ThenInclude(c => c.People)
.Include(vol => vol.Chapters)
.ThenInclude(c => c.Tags)
.OrderBy(volume => volume.Number)
.OrderBy(volume => volume.MinNumber)
.ProjectTo<VolumeDto>(_mapper.ConfigurationProvider)
.AsNoTracking()
.AsSplitQuery()
@ -215,7 +215,7 @@ public class VolumeRepository : IVolumeRepository
private static void SortSpecialChapters(IEnumerable<VolumeDto> 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();
}
@ -241,7 +241,9 @@ public class VolumeRepository : IVolumeRepository
c.LastReadingProgress = progresses.Max(p => p.LastModified);
}
v.PagesRead = userProgress.Where(p => p.VolumeId == v.Id).Sum(p => p.PagesRead);
v.PagesRead = userProgress
.Where(p => p.VolumeId == v.Id)
.Sum(p => p.PagesRead);
}
}
}

View file

@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
@ -208,8 +207,9 @@ public static class Seed
{
new() {Key = ServerSettingKey.CacheDirectory, Value = directoryService.CacheDirectory},
new() {Key = ServerSettingKey.TaskScan, Value = "daily"},
new() {Key = ServerSettingKey.LoggingLevel, Value = "Debug"},
new() {Key = ServerSettingKey.TaskBackup, Value = "daily"},
new() {Key = ServerSettingKey.TaskCleanup, Value = "daily"},
new() {Key = ServerSettingKey.LoggingLevel, Value = "Debug"},
new()
{
Key = ServerSettingKey.BackupDirectory, Value = Path.GetFullPath(DirectoryService.BackupDirectory)
@ -223,12 +223,10 @@ public static class Seed
}, // Not used from DB, but DB is sync with appSettings.json
new() {Key = ServerSettingKey.AllowStatCollection, Value = "true"},
new() {Key = ServerSettingKey.EnableOpds, Value = "true"},
new() {Key = ServerSettingKey.EnableAuthentication, Value = "true"},
new() {Key = ServerSettingKey.BaseUrl, Value = "/"},
new() {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()},
new() {Key = ServerSettingKey.InstallVersion, Value = BuildInfo.Version.ToString()},
new() {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory},
new() {Key = ServerSettingKey.EmailServiceUrl, Value = EmailService.DefaultApiUrl},
new() {Key = ServerSettingKey.TotalBackups, Value = "30"},
new() {Key = ServerSettingKey.TotalLogs, Value = "30"},
new() {Key = ServerSettingKey.EnableFolderWatching, Value = "false"},
@ -241,6 +239,16 @@ public static class Seed
new() {
Key = ServerSettingKey.CacheSize, Value = Configuration.DefaultCacheMemory + string.Empty
}, // Not used from DB, but DB is sync with appSettings.json
new() {Key = ServerSettingKey.EmailHost, Value = string.Empty},
new() {Key = ServerSettingKey.EmailPort, Value = string.Empty},
new() {Key = ServerSettingKey.EmailAuthPassword, Value = string.Empty},
new() {Key = ServerSettingKey.EmailAuthUserName, Value = string.Empty},
new() {Key = ServerSettingKey.EmailSenderAddress, Value = string.Empty},
new() {Key = ServerSettingKey.EmailSenderDisplayName, Value = string.Empty},
new() {Key = ServerSettingKey.EmailEnableSsl, Value = "true"},
new() {Key = ServerSettingKey.EmailSizeLimit, Value = 26_214_400 + string.Empty},
new() {Key = ServerSettingKey.EmailCustomizedTemplates, Value = "false"},
}.ToArray());
foreach (var defaultSetting in DefaultSettings)

View file

@ -30,6 +30,7 @@ public interface IUnitOfWork
IUserTableOfContentRepository UserTableOfContentRepository { get; }
IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; }
IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; }
IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; }
bool Commit();
Task<bool> CommitAsync();
bool HasChanges();
@ -48,7 +49,7 @@ public class UnitOfWork : IUnitOfWork
_userManager = userManager;
}
public ISeriesRepository SeriesRepository => new SeriesRepository(_context, _mapper);
public ISeriesRepository SeriesRepository => new SeriesRepository(_context, _mapper, _userManager);
public IUserRepository UserRepository => new UserRepository(_context, _userManager, _mapper);
public ILibraryRepository LibraryRepository => new LibraryRepository(_context, _mapper);
@ -72,6 +73,7 @@ public class UnitOfWork : IUnitOfWork
public IUserTableOfContentRepository UserTableOfContentRepository => new UserTableOfContentRepository(_context, _mapper);
public IAppUserSmartFilterRepository AppUserSmartFilterRepository => new AppUserSmartFilterRepository(_context, _mapper);
public IAppUserExternalSourceRepository AppUserExternalSourceRepository => new AppUserExternalSourceRepository(_context, _mapper);
public IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository => new ExternalSeriesMetadataRepository(_context, _mapper);
/// <summary>
/// Commits changes to the DB. Completes the open transaction.

View file

@ -0,0 +1,27 @@
<tr>
<td>
<h1>Email Change Update</h1>
<p>Your account's email has been updated on {{InvitingUser}}'s Kavita instance.</p>
<p>Please click the following link to validate your email change. The email is not changed until you complete validation.</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="center">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td> <a href="{{Link}}" target="_blank">CONFIRM EMAIL</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p class="small">If the button above does not work, please find the link here: <a class="small" href="{{Link}}" target="_blank">{{Link}}</a></p>
</td>
</tr>

View file

@ -0,0 +1,26 @@
<tr>
<td>
<h1>You've Been Invited!</h1>
<p>You have been invited to {{InvitingUser}}'s Kavita instance.</p>
<p>Please click the following link to setup an account for yourself and start reading.</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="center">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td> <a href="{{Link}}" target="_blank">ACCEPT INVITE</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p class="small">If the button above does not work, please find the link here: <a class="small" href="{{Link}}" target="_blank">{{Link}}</a></p>
</td>
</tr>

View file

@ -0,0 +1,27 @@
<tr>
<td>
<h1>Forgot your password?</h1>
<p>That's okay, it happens! Click on the button below to reset your password.</p>
<p>If you did not perform this action, ignore this email. Your account is safe.</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="center">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td> <a href="{{Link}}" target="_blank">RESET YOUR PASSWORD</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p class="small">If the button above does not work, please find the link here: <a class="small" href="{{Link}}" target="_blank">{{Link}}</a></p>
</td>
</tr>

View file

@ -0,0 +1,8 @@
<tr>
<td>
<h1>This is a Test Email</h1>
<p>Congrats! Your instance of Kavita is setup to email correctly!</p>
<p>If you did not perform this action, ignore this email. Your account is safe.</p>
</td>
</tr>

View file

@ -0,0 +1,6 @@
<tr>
<td>
<h1>You sent file(s) from Kavita</h1>
<p>Please find attached the file(s) you've sent.</p>
</td>
</tr>

View file

@ -0,0 +1,527 @@
<!doctype html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<!-- utf-8 works for most cases -->
<meta name="viewport" content="width=device-width">
<!-- Forcing initial-scale shouldn't be necessary -->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!-- Use the latest (edge) version of IE rendering engine -->
<meta name="x-apple-disable-message-reformatting">
<!-- Disable auto-scale in iOS 10 Mail entirely -->
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<!-- Supports dark mode -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Spartan:wght@500;700&display=swap" rel="stylesheet">
<title>Kavita - {{Preheader}}</title>
<style>
/* -------------------------------------
GLOBAL RESETS
------------------------------------- */
/*All the styling goes here*/
:root {
Color-scheme: light dark;
supported-color-schemes: light dark;
}
.dark-img { display: none !important; }
@media (prefers-color-scheme: dark) {
.dark-img {
display: inline-block !important;
}
.light-img {
display: none !important;
}
}
[data-ogsc] .dark-img {
display: inline-block !important;
}
[data-ogsc] .light-img {
display: none !important;
}
img {
border: none;
-ms-interpolation-mode: bicubic;
max-width: 100%;
}
body {
background-color: #eaebed;
font-family: sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 14px;
line-height: 1.4;
margin: 0;
padding: 0;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
table {
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
min-width: 100%;
width: 100%; }
table td {
font-family: sans-serif;
font-size: 14px;
vertical-align: top;
}
/* -------------------------------------
BODY & CONTAINER
------------------------------------- */
.body {
background-color: #eaebed;
width: 100%;
}
/* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */
.container {
display: block;
margin: 0 auto !important;
/* makes it centered */
max-width: 680px;
width: 680px;
}
/* This should also be a block element, so that it will fill 100% of the .container */
.content {
box-sizing: border-box;
display: block;
margin: 0 auto;
max-width: 680px;
padding: 0;
}
/* -------------------------------------
HEADER, FOOTER, MAIN
------------------------------------- */
.main {
background: #ffffff;
border-radius: 3px;
width: 100%;
}
.header {
padding: 10px 40px;
background-color: #4AC694;
background-image: linear-gradient(#4AC694, #4AC694);
text-align: center;
}
u + .body .gmail-blend-screen {
background:#000;
mix-blend-mode:screen;
}
u + .body .gmail-blend-difference {
background:#000;
mix-blend-mode:difference;
}
@media (prefers-color-scheme: dark ) {
.header {
background-color: transparent !important;
}
.header-text {
color: #fffffe !important;
}
.logo-text {
color: #fffffe !important;
}
}
.header-text, .logo-text {
font-weight: 700;
font-family: 'Spartan', sans-serif;
text-decoration: none;
color: #ffffff;
font-size: 2rem;
vertical-align: middle;
margin: 0
}
.logo-text {
color: #fff;
}
.header-text img {
vertical-align: middle;
width: 200px;
}
.header-text div, .header-text p {
vertical-align: middle;
min-height: 75px;
line-height: 75px;
display: inline-block;
}
.wrapper {
box-sizing: border-box;
padding: 20px;
}
.content-block {
padding-bottom: 10px;
padding-top: 10px;
}
.content-block > * {
padding-right: 10px;
}
.footer {
clear: both;
text-align: center;
width: 100%;
background-color: #222;
background-image: linear-gradient(#222, #222);
color: #fffffe;
}
.footer td,
.footer p,
.footer span,
.footer a {
color: #fffffe;
font-size: 12px;
text-align: center;
}
.footer .icon {
margin:0;
padding:0 10px;
border:none;
display:inline-block;
}
@media (prefers-color-scheme: dark ) {
.footer {
background-color: rgba(0,0,0,0) !important;
background-image: linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0)) !important;
}
}
/* -------------------------------------
TYPOGRAPHY
------------------------------------- */
h1,
h2,
h3,
h4 {
color: #06090f;
font-family: sans-serif;
font-weight: 400;
line-height: 1.4;
margin: 0;
margin-bottom: 30px;
}
h1 {
font-size: 35px;
font-weight: 300;
text-align: center;
text-transform: capitalize;
}
p,
ul,
ol {
font-family: sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
margin-bottom: 15px;
}
p li,
ul li,
ol li {
list-style-position: inside;
margin-left: 5px;
}
a {
color: #4AC694;
text-decoration: underline;
}
/* -------------------------------------
BUTTONS
------------------------------------- */
.btn {
box-sizing: border-box;
width: 100%; }
.btn > tbody > tr > td {
padding-bottom: 15px; }
.btn table {
min-width: auto;
width: auto;
}
.btn table td {
background-color: #ffffff;
border-radius: 5px;
text-align: center;
}
.btn a {
background-color: #ffffff;
border: solid 1px #4AC694;
border-radius: 5px;
box-sizing: border-box;
color: #4AC694;
cursor: pointer;
display: inline-block;
font-size: 14px;
font-weight: bold;
margin: 0;
padding: 12px 25px;
text-decoration: none;
text-transform: capitalize;
}
.btn-primary table td {
background-color: #4AC694;
background-image: linear-gradient(#4AC694, #4AC694);
}
.btn-primary a {
background-color: #4AC694;
background-image: linear-gradient(#4AC694, #4AC694);
border-color: #4AC694;
color: #fffffe;
}
.small {
font-size: 10px;
color: #222 !important;
}
/* -------------------------------------
OTHER STYLES THAT MIGHT BE USEFUL
------------------------------------- */
.last {
margin-bottom: 0;
}
.first {
margin-top: 0;
}
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.clear {
clear: both;
}
.mt0 {
margin-top: 0;
}
.mb0 {
margin-bottom: 0;
}
.preheader {
color: transparent;
display: none;
height: 0;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
mso-hide: all;
visibility: hidden;
width: 0;
}
.powered-by a {
text-decoration: none;
}
hr {
border: 0;
border-bottom: 1px solid #f6f6f6;
Margin: 20px 0;
}
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class=body] .btn table {
width: 100% !important;
}
table[class=body] .btn a {
width: 100% !important;
}
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
.btn-primary table td:hover {
background-color: #d5075d !important;
}
.btn-primary a:hover {
background-color: #d5075d !important;
border-color: #d5075d !important;
}
}
</style>
</head>
<body class="">
<!-- Visually Hidden Preheader Text-->
<div style="display:none;font-size:1px;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;mso-hide:all;font-family: sans-serif;">{{Preheader}}</div>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body">
<tr>
<td class="container">
<div class="header">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td class="align-center" width="100%">
<a class="header-text" href="https://www.kavitareader.com" target="_blank">
<img class="light-img" src="https://www.kavitareader.com/img/email/email-logo.png" alt="Kavita" width="40" style="vertical-align:middle;" />
<!--[if !mso]><! -->
<img class="dark-img" src="https://www.kavitareader.com/img/email/email-logo.png" alt="Kavita" width="40" style="vertical-align:middle;" />
<!--<![endif]-->
</a>
</td>
</tr>
</table>
</div>
<div class="content">
<table role="presentation" class="main">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
{{Body}}
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- START FOOTER -->
<div class="footer">
<div class="gmail-blend-screen">
<div class="gmail-blend-difference">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="content-block" style="text-align: center;">
<table align="center" style="text-align: center;">
<tr>
<td class="icon">
<a href="https://discord.gg/b52wT37kt7" target="_blank">
Discord
</a>
</td>
<td class="icon">
<a href="https://github.com/Kareadita/Kavita/">
Github
</a>
</td>
<td class="icon">
<a href="https://wiki.kavitareader.com/en">
Wiki
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
</div>
</div>
<!-- END FOOTER -->
<!-- END CENTERED WHITE CONTAINER -->
</div>
</td>
</tr>
</table>
</body>
</html>

View file

@ -31,7 +31,7 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
/// <summary>
/// A list of Series the user want's to read
/// </summary>
public ICollection<Series> WantToRead { get; set; } = null!;
public ICollection<AppUserWantToRead> WantToRead { get; set; } = null!;
/// <summary>
/// A list of Devices which allows the user to send files to
/// </summary>

View file

@ -0,0 +1,20 @@
namespace API.Entities;
public class AppUserWantToRead
{
public int Id { get; set; }
public required int SeriesId { get; set; }
public virtual Series Series { get; set; }
// Relationships
/// <summary>
/// Navigational Property for EF. Links to a unique AppUser
/// </summary>
public AppUser AppUser { get; set; } = null!;
/// <summary>
/// User this table of content belongs to
/// </summary>
public int AppUserId { get; set; }
}

View file

@ -25,8 +25,13 @@ public enum LibraryType
[Description("Image")]
Image = 3,
/// <summary>
/// Allows Books to Scrobble with AniList for Kavita+
/// </summary>
[Description("Light Novel")]
LightNovel = 4,
/// <summary>
/// Uses Magazine regex and is restricted to PDF and Archive by default
/// </summary>
[Description("Magazine")]
Magazine = 4
Magazine = 5
}

View file

@ -52,6 +52,7 @@ public enum ServerSettingKey
/// Is Authentication needed for non-admin accounts
/// </summary>
/// <remarks>Deprecated. This is no longer used v0.5.1+. Assume Authentication is always in effect</remarks>
[Obsolete("Not supported as of v0.5.1")]
[Description("EnableAuthentication")]
EnableAuthentication = 8,
/// <summary>
@ -79,6 +80,7 @@ public enum ServerSettingKey
/// If SMTP is enabled on the server
/// </summary>
[Description("CustomEmailService")]
[Obsolete("Use Email settings instead")]
EmailServiceUrl = 13,
/// <summary>
/// If Kavita should save bookmarks as WebP images
@ -147,6 +149,42 @@ public enum ServerSettingKey
/// The size of the cover image thumbnail. Defaults to <see cref="CoverImageSize"/>.Default
/// </summary>
[Description("CoverImageSize")]
CoverImageSize = 27
CoverImageSize = 27,
#region EmailSettings
/// <summary>
/// The address of the emailer host
/// </summary>
[Description("EmailSenderAddress")]
EmailSenderAddress = 28,
/// <summary>
/// What the email name should be
/// </summary>
[Description("EmailSenderDisplayName")]
EmailSenderDisplayName = 29,
[Description("EmailAuthUserName")]
EmailAuthUserName = 30,
[Description("EmailAuthPassword")]
EmailAuthPassword = 31,
[Description("EmailHost")]
EmailHost = 32,
[Description("EmailPort")]
EmailPort = 33,
[Description("EmailEnableSsl")]
EmailEnableSsl = 34,
/// <summary>
/// Number of bytes that the sender allows to be sent through
/// </summary>
[Description("EmailSizeLimit")]
EmailSizeLimit = 35,
/// <summary>
/// Should Kavita use config/templates for Email templates or the default ones
/// </summary>
[Description("EmailCustomizedTemplates")]
EmailCustomizedTemplates = 36,
#endregion
/// <summary>
/// When the cleanup task should run - Critical to keeping Kavita working
/// </summary>
[Description("TaskCleanup")]
TaskCleanup = 37
}

View file

@ -0,0 +1,14 @@
using System;
namespace API.Entities;
/// <summary>
/// This will track manual migrations so that I can use simple selects to check if a Manual Migration is needed
/// </summary>
public class ManualMigrationHistory
{
public int Id { get; set; }
public string ProductVersion { get; set; }
public required string Name { get; set; }
public DateTime RanAt { get; set; }
}

View file

@ -0,0 +1,17 @@
using System.Collections.Generic;
using API.Services.Plus;
namespace API.Entities.Metadata;
public class ExternalRating
{
public int Id { get; set; }
public int AverageScore { get; set; }
public int FavoriteCount { get; set; }
public ScrobbleProvider Provider { get; set; }
public string? ProviderUrl { get; set; }
public int SeriesId { get; set; }
public ICollection<ExternalSeriesMetadata> ExternalSeriesMetadatas { get; set; } = null!;
}

View file

@ -0,0 +1,29 @@
using System.Collections.Generic;
using API.Services.Plus;
using Microsoft.EntityFrameworkCore;
namespace API.Entities.Metadata;
[Index(nameof(SeriesId), IsUnique = false)]
public class ExternalRecommendation
{
public int Id { get; set; }
public required string Name { get; set; }
public required string CoverUrl { get; set; }
public required string Url { get; set; }
public string? Summary { get; set; }
public int? AniListId { get; set; }
public long? MalId { get; set; }
public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.AniList;
/// <summary>
/// When null, represents an external series. When set, it is a Series
/// </summary>
public int? SeriesId { get; set; }
//public virtual Series? Series { get; set; }
// Relationships
public ICollection<ExternalSeriesMetadata> ExternalSeriesMetadatas { get; set; } = null!;
}

View file

@ -0,0 +1,43 @@
using System.Collections.Generic;
using API.Services.Plus;
namespace API.Entities.Metadata;
/// <summary>
/// Represents an Externally supplied Review for a given Series
/// </summary>
public class ExternalReview
{
public int Id { get; set; }
public string Tagline { get; set; }
public required string Body { get; set; }
/// <summary>
/// Pure text version of the body
/// </summary>
public required string BodyJustText { get; set; }
/// <summary>
/// Raw from the provider. Usually Markdown
/// </summary>
public string RawBody { get; set; }
public required ScrobbleProvider Provider { get; set; }
public string SiteUrl { get; set; }
/// <summary>
/// Reviewer's username
/// </summary>
public string Username { get; set; }
/// <summary>
/// An Optional Rating coming from the Review
/// </summary>
public int Rating { get; set; } = 0;
/// <summary>
/// The media's overall Score
/// </summary>
public int Score { get; set; }
public int TotalVotes { get; set; }
public int SeriesId { get; set; }
// Relationships
public ICollection<ExternalSeriesMetadata> ExternalSeriesMetadatas { get; set; } = null!;
}

Some files were not shown because too many files have changed in this diff Show more