diff --git a/.github/workflows/sonar-scan.yml b/.github/workflows/sonar-scan.yml index c58c177b1..dafab4f2e 100644 --- a/.github/workflows/sonar-scan.yml +++ b/.github/workflows/sonar-scan.yml @@ -8,17 +8,25 @@ on: types: [synchronize] jobs: + check_pr: + runs-on: ubuntu-latest + steps: + - name: Check PR Body + uses: JJ/github-pr-contains-action@releases/v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + bodyDoesNotContain: "["|`]" build: name: Build .Net runs-on: windows-latest steps: - name: Checkout Repo - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 - name: Setup .NET Core - uses: actions/setup-dotnet@v2 + uses: actions/setup-dotnet@v3 with: dotnet-version: 7.0.x @@ -74,19 +82,18 @@ jobs: - name: Test run: dotnet test --no-restore --verbosity normal - version: name: Bump version on Develop push needs: [ build ] runs-on: ubuntu-latest if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Setup .NET Core - uses: actions/setup-dotnet@v2 + uses: actions/setup-dotnet@v3 with: dotnet-version: 7.0.x @@ -139,7 +146,7 @@ jobs: echo "::set-output name=BODY::$body" - name: Check Out Repo - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: ref: develop @@ -176,7 +183,7 @@ jobs: run: echo "${{steps.get-version.outputs.assembly-version}}" - name: Compile dotnet app - uses: actions/setup-dotnet@v2 + uses: actions/setup-dotnet@v3 with: dotnet-version: 7.0.x @@ -253,7 +260,7 @@ jobs: echo "::set-output name=BODY::$body" - name: Check Out Repo - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: ref: main @@ -293,7 +300,7 @@ jobs: id: parse-version - name: Compile dotnet app - uses: actions/setup-dotnet@v2 + uses: actions/setup-dotnet@v3 with: dotnet-version: 7.0.x - name: Install Swashbuckle CLI diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index 227d4c4df..1af28971f 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -6,11 +6,12 @@ - + - + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/API.Tests/Helpers/PersonHelperTests.cs b/API.Tests/Helpers/PersonHelperTests.cs index d9f5cdb82..0f1c170ff 100644 --- a/API.Tests/Helpers/PersonHelperTests.cs +++ b/API.Tests/Helpers/PersonHelperTests.cs @@ -9,6 +9,7 @@ namespace API.Tests.Helpers; public class PersonHelperTests { + #region UpdatePeople [Fact] public void UpdatePeople_ShouldAddNewPeople() { @@ -47,7 +48,15 @@ public class PersonHelperTests Assert.Equal(3, allPeople.Count); } + #endregion + #region UpdatePeopleList + + + + #endregion + + #region RemovePeople [Fact] public void RemovePeople_ShouldRemovePeopleOfSameRole() { @@ -111,6 +120,10 @@ public class PersonHelperTests Assert.Equal(2, peopleRemoved.Count); } + + #endregion + + #region KeepOnlySamePeopleBetweenLists [Fact] public void KeepOnlySamePeopleBetweenLists() { @@ -135,6 +148,9 @@ public class PersonHelperTests Assert.Equal(2, peopleRemoved.Count); } + #endregion + + #region AddPeople [Fact] public void AddPeople_ShouldAddOnlyNonExistingPeople() @@ -157,4 +173,6 @@ public class PersonHelperTests Assert.Equal(4, existingPeople.Count); } + #endregion + } diff --git a/API.Tests/Parser/MangaParserTests.cs b/API.Tests/Parser/MangaParserTests.cs index 608a08911..208ace3bc 100644 --- a/API.Tests/Parser/MangaParserTests.cs +++ b/API.Tests/Parser/MangaParserTests.cs @@ -81,6 +81,7 @@ public class MangaParserTests [InlineData("몰?루 아카이브 7.5권", "7.5")] [InlineData("63권#200", "63")] [InlineData("시즌34삽화2", "34")] + [InlineData("Accel World Chapter 001 Volume 002", "2")] public void ParseVolumeTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseVolume(filename)); @@ -195,6 +196,7 @@ public class MangaParserTests [InlineData("Манга Том 1 3-4 Глава", "Манга")] [InlineData("Esquire 6권 2021년 10월호", "Esquire")] [InlineData("Accel World: Vol 1", "Accel World")] + [InlineData("Accel World Chapter 001 Volume 002", "Accel World")] public void ParseSeriesTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename)); @@ -278,6 +280,7 @@ public class MangaParserTests [InlineData("Манга Глава 2", "2")] [InlineData("Манга 2 Глава", "2")] [InlineData("Манга Том 1 2 Глава", "2")] + [InlineData("Accel World Chapter 001 Volume 002", "1")] public void ParseChaptersTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseChapter(filename)); diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs index d174c6f44..bf243ccc1 100644 --- a/API.Tests/Services/CleanupServiceTests.cs +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; @@ -16,7 +17,6 @@ using API.Helpers.Builders; using API.Services; using API.Services.Tasks; using API.SignalR; -using API.Tests.Helpers; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -437,7 +437,8 @@ public class CleanupServiceTests : AbstractDbTest await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For(), + Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem())); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); await readerService.MarkChaptersUntilAsRead(user, 1, 5); @@ -534,7 +535,8 @@ public class CleanupServiceTests : AbstractDbTest await _unitOfWork.CommitAsync(); var readerService = new ReaderService(_unitOfWork, Substitute.For>(), - Substitute.For()); + Substitute.For(), Substitute.For(), + new DirectoryService(Substitute.For>(), new MockFileSystem())); await readerService.MarkSeriesAsRead(user, s.Id); await _unitOfWork.CommitAsync(); diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index 19dc26d71..e74687e54 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -29,10 +29,9 @@ namespace API.Tests.Services; public class ReaderServiceTests { private readonly ITestOutputHelper _testOutputHelper; - private readonly IUnitOfWork _unitOfWork; - private readonly DataContext _context; + private readonly ReaderService _readerService; private const string CacheDirectory = "C:/kavita/config/cache/"; private const string CoverImageDirectory = "C:/kavita/config/covers/"; @@ -50,6 +49,9 @@ public class ReaderServiceTests var config = new MapperConfiguration(cfg => cfg.AddProfile()); var mapper = config.CreateMapper(); _unitOfWork = new UnitOfWork(_context, mapper, null); + _readerService = new ReaderService(_unitOfWork, Substitute.For>(), + Substitute.For(), Substitute.For(), + new DirectoryService(Substitute.For>(), new MockFileSystem())); } #region Setup @@ -147,10 +149,9 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - Assert.Equal(0, await readerService.CapPageToChapter(1, -1)); - Assert.Equal(1, await readerService.CapPageToChapter(1, 10)); + Assert.Equal(0, await _readerService.CapPageToChapter(1, -1)); + Assert.Equal(1, await _readerService.CapPageToChapter(1, 10)); } #endregion @@ -186,9 +187,9 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var successful = await readerService.SaveReadingProgress(new ProgressDto() + + var successful = await _readerService.SaveReadingProgress(new ProgressDto() { ChapterId = 1, PageNum = 1, @@ -230,9 +231,9 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var successful = await readerService.SaveReadingProgress(new ProgressDto() + + var successful = await _readerService.SaveReadingProgress(new ProgressDto() { ChapterId = 1, PageNum = 1, @@ -244,7 +245,7 @@ public class ReaderServiceTests Assert.True(successful); Assert.NotNull(await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)); - Assert.True(await readerService.SaveReadingProgress(new ProgressDto() + Assert.True(await _readerService.SaveReadingProgress(new ProgressDto() { ChapterId = 1, PageNum = 1, @@ -294,10 +295,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var volumes = await _unitOfWork.VolumeRepository.GetVolumes(1); - await readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); + await _readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); await _context.SaveChangesAsync(); Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); @@ -338,15 +339,15 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(1)).ToList(); - await readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); + await _readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); await _context.SaveChangesAsync(); Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); - await readerService.MarkChaptersAsUnread(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); + await _readerService.MarkChaptersAsUnread(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); await _context.SaveChangesAsync(); var progresses = (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses; @@ -427,9 +428,9 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 1, 1); + + var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 1, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.Equal("2", actualChapter.Range); } @@ -475,10 +476,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1); + + var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.Equal("21", actualChapter.Range); } @@ -524,10 +525,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1); + + var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.Equal("21", actualChapter.Range); } @@ -566,10 +567,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var nextChapter = await readerService.GetNextChapterIdAsync(1, 2, 4, 1); + + var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 4, 1); Assert.NotEqual(-1, nextChapter); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.Equal("1", actualChapter.Range); @@ -614,9 +615,9 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var nextChapter = await readerService.GetNextChapterIdAsync(1, 2, 3, 1); + + var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 3, 1); Assert.NotEqual(-1, nextChapter); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.Equal("0", actualChapter.Range); @@ -658,10 +659,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var nextChapter = await readerService.GetNextChapterIdAsync(1, 2, 4, 1); + + var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 4, 1); Assert.Equal(-1, nextChapter); } @@ -693,10 +694,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1); + + var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); Assert.Equal(-1, nextChapter); } @@ -735,10 +736,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1); + + var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); Assert.Equal(-1, nextChapter); } @@ -777,10 +778,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1); + + var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); Assert.NotEqual(-1, nextChapter); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.Equal("A.cbz", actualChapter.Range); @@ -815,10 +816,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1); + + var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); Assert.NotEqual(-1, nextChapter); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.Equal("A.cbz", actualChapter.Range); @@ -857,10 +858,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 3, 1); + + var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 3, 1); Assert.Equal(-1, nextChapter); } @@ -898,10 +899,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var nextChapter = await readerService.GetNextChapterIdAsync(1, 2, 3, 1); + + var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 3, 1); Assert.NotEqual(-1, nextChapter); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.Equal("B.cbz", actualChapter.Range); @@ -952,9 +953,9 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 2, 1); + + var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 1, 2, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.Equal("1", actualChapter.Range); } @@ -998,9 +999,9 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var prevChapter = await readerService.GetPrevChapterIdAsync(1, 3, 5, 1); + + var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 3, 5, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.Equal("22", actualChapter.Range); } @@ -1044,10 +1045,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + // prevChapter should be id from ch.21 from volume 2001 - var prevChapter = await readerService.GetPrevChapterIdAsync(1, 4, 7, 1); + var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 4, 7, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.NotNull(actualChapter); @@ -1091,10 +1092,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2, 3, 1); + + var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 2, 3, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.Equal("2", actualChapter.Range); } @@ -1133,10 +1134,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2, 3, 1); + + var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 2, 3, 1); Assert.Equal(2, prevChapter); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.Equal("2", actualChapter.Range); @@ -1170,10 +1171,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1); + + var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 1, 1, 1); Assert.Equal(-1, prevChapter); } @@ -1204,10 +1205,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1); + + var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 1, 1, 1); Assert.Equal(-1, prevChapter); } @@ -1244,10 +1245,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1); + + var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 1, 1, 1); Assert.Equal(-1, prevChapter); } @@ -1293,14 +1294,14 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2,5, 1); + + var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 2,5, 1); var chapterInfoDto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(prevChapter); Assert.Equal(1, float.Parse(chapterInfoDto.ChapterNumber)); // This is first chapter of first volume - prevChapter = await readerService.GetPrevChapterIdAsync(1, 2,4, 1); + prevChapter = await _readerService.GetPrevChapterIdAsync(1, 2,4, 1); Assert.Equal(-1, prevChapter); } @@ -1332,10 +1333,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1); + + var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 1, 1, 1); Assert.Equal(-1, prevChapter); } @@ -1374,10 +1375,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2, 4, 1); + + var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 2, 4, 1); Assert.NotEqual(-1, prevChapter); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.Equal("A.cbz", actualChapter.Range); @@ -1418,10 +1419,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1); + + var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 1, 1, 1); Assert.NotEqual(-1, prevChapter); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.Equal("22", actualChapter.Range); @@ -1472,9 +1473,9 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var nextChapter = await readerService.GetContinuePoint(1, 1); + + var nextChapter = await _readerService.GetContinuePoint(1, 1); Assert.Equal("1", nextChapter.Range); } @@ -1511,15 +1512,15 @@ public class ReaderServiceTests - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - await readerService.SaveReadingProgress(new ProgressDto() + + await _readerService.SaveReadingProgress(new ProgressDto() { PageNum = 2, ChapterId = 1, SeriesId = 1, VolumeId = 1 }, 1); - var nextChapter = await readerService.GetContinuePoint(1, 1); + var nextChapter = await _readerService.GetContinuePoint(1, 1); Assert.Equal("1", nextChapter.Range); } @@ -1560,24 +1561,24 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + // Save progress on first volume chapters and 1st of second volume - await readerService.SaveReadingProgress(new ProgressDto() + await _readerService.SaveReadingProgress(new ProgressDto() { PageNum = 1, ChapterId = 1, SeriesId = 1, VolumeId = 1 }, 1); - await readerService.SaveReadingProgress(new ProgressDto() + await _readerService.SaveReadingProgress(new ProgressDto() { PageNum = 1, ChapterId = 2, SeriesId = 1, VolumeId = 1 }, 1); - await readerService.SaveReadingProgress(new ProgressDto() + await _readerService.SaveReadingProgress(new ProgressDto() { PageNum = 1, ChapterId = 3, @@ -1587,7 +1588,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var nextChapter = await readerService.GetContinuePoint(1, 1); + var nextChapter = await _readerService.GetContinuePoint(1, 1); Assert.Equal("22", nextChapter.Range); @@ -1638,10 +1639,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + // Save progress on first volume and 1st chapter of second volume - await readerService.SaveReadingProgress(new ProgressDto() + await _readerService.SaveReadingProgress(new ProgressDto() { PageNum = 1, ChapterId = 6, // Chapter 0 volume 1 id @@ -1650,7 +1651,7 @@ public class ReaderServiceTests }, 1); - await readerService.SaveReadingProgress(new ProgressDto() + await _readerService.SaveReadingProgress(new ProgressDto() { PageNum = 1, ChapterId = 7, // Chapter 21 volume 2 id @@ -1660,7 +1661,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var nextChapter = await readerService.GetContinuePoint(1, 1); + var nextChapter = await _readerService.GetContinuePoint(1, 1); Assert.Equal("22", nextChapter.Range); @@ -1702,24 +1703,24 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + // Save progress on first volume chapters and 1st of second volume - await readerService.SaveReadingProgress(new ProgressDto() + await _readerService.SaveReadingProgress(new ProgressDto() { PageNum = 1, ChapterId = 1, SeriesId = 1, VolumeId = 1 }, 1); - await readerService.SaveReadingProgress(new ProgressDto() + await _readerService.SaveReadingProgress(new ProgressDto() { PageNum = 1, ChapterId = 2, SeriesId = 1, VolumeId = 1 }, 1); - await readerService.SaveReadingProgress(new ProgressDto() + await _readerService.SaveReadingProgress(new ProgressDto() { PageNum = 1, ChapterId = 3, @@ -1729,7 +1730,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var nextChapter = await readerService.GetContinuePoint(1, 1); + var nextChapter = await _readerService.GetContinuePoint(1, 1); Assert.Equal("31", nextChapter.Range); } @@ -1769,8 +1770,8 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var nextChapter = await readerService.GetContinuePoint(1, 1); + + var nextChapter = await _readerService.GetContinuePoint(1, 1); Assert.Equal("1", nextChapter.Range); } @@ -1811,21 +1812,21 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + // Mark everything but chapter 101 as read - await readerService.MarkSeriesAsRead(user, 1); + await _readerService.MarkSeriesAsRead(user, 1); await _unitOfWork.CommitAsync(); // Unmark last chapter as read - await readerService.SaveReadingProgress(new ProgressDto() + await _readerService.SaveReadingProgress(new ProgressDto() { PageNum = 0, ChapterId = (await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(1)).Chapters.ElementAt(1).Id, SeriesId = 1, VolumeId = 1 }, 1); - await readerService.SaveReadingProgress(new ProgressDto() + await _readerService.SaveReadingProgress(new ProgressDto() { PageNum = 0, ChapterId = (await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(1)).Chapters.ElementAt(2).Id, @@ -1834,7 +1835,7 @@ public class ReaderServiceTests }, 1); await _context.SaveChangesAsync(); - var nextChapter = await readerService.GetContinuePoint(1, 1); + var nextChapter = await _readerService.GetContinuePoint(1, 1); Assert.Equal("101", nextChapter.Range); } @@ -1869,24 +1870,24 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + // Save progress on first volume chapters and 1st of second volume - await readerService.SaveReadingProgress(new ProgressDto() + await _readerService.SaveReadingProgress(new ProgressDto() { PageNum = 1, ChapterId = 1, SeriesId = 1, VolumeId = 1 }, 1); - await readerService.SaveReadingProgress(new ProgressDto() + await _readerService.SaveReadingProgress(new ProgressDto() { PageNum = 1, ChapterId = 2, SeriesId = 1, VolumeId = 1 }, 1); - await readerService.SaveReadingProgress(new ProgressDto() + await _readerService.SaveReadingProgress(new ProgressDto() { PageNum = 1, ChapterId = 3, @@ -1896,7 +1897,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var nextChapter = await readerService.GetContinuePoint(1, 1); + var nextChapter = await _readerService.GetContinuePoint(1, 1); Assert.Equal("1", nextChapter.Range); } @@ -1933,14 +1934,14 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + // Save progress on first volume chapters and 1st of second volume var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); - await readerService.MarkSeriesAsRead(user, 1); + await _readerService.MarkSeriesAsRead(user, 1); await _context.SaveChangesAsync(); - var nextChapter = await readerService.GetContinuePoint(1, 1); + var nextChapter = await _readerService.GetContinuePoint(1, 1); Assert.Equal("11", nextChapter.Range); } @@ -1973,24 +1974,24 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + // Save progress on first volume chapters and 1st of second volume - await readerService.SaveReadingProgress(new ProgressDto() + await _readerService.SaveReadingProgress(new ProgressDto() { PageNum = 1, ChapterId = 1, SeriesId = 1, VolumeId = 1 }, 1); - await readerService.SaveReadingProgress(new ProgressDto() + await _readerService.SaveReadingProgress(new ProgressDto() { PageNum = 1, ChapterId = 2, SeriesId = 1, VolumeId = 1 }, 1); - await readerService.SaveReadingProgress(new ProgressDto() + await _readerService.SaveReadingProgress(new ProgressDto() { PageNum = 1, ChapterId = 3, @@ -2000,7 +2001,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var nextChapter = await readerService.GetContinuePoint(1, 1); + var nextChapter = await _readerService.GetContinuePoint(1, 1); Assert.Equal("Some Special Title", nextChapter.Range); } @@ -2041,9 +2042,9 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); - await readerService.MarkSeriesAsRead(user, 1); + await _readerService.MarkSeriesAsRead(user, 1); await _context.SaveChangesAsync(); // Add 2 new unread series to the Series @@ -2053,7 +2054,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); // This tests that if you add a series later to a volume and a loose leaf chapter, we continue from that volume, rather than loose leaf - var nextChapter = await readerService.GetContinuePoint(1, 1); + var nextChapter = await _readerService.GetContinuePoint(1, 1); Assert.Equal("14.9", nextChapter.Range); } @@ -2104,18 +2105,18 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + // Save progress on first volume chapters and 1st of second volume var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); - await readerService.MarkChaptersAsRead(user, 1, + await _readerService.MarkChaptersAsRead(user, 1, new List() { readChapter1, readChapter2 }); await _context.SaveChangesAsync(); - var nextChapter = await readerService.GetContinuePoint(1, 1); + var nextChapter = await _readerService.GetContinuePoint(1, 1); Assert.Equal(4, nextChapter.VolumeId); } @@ -2152,10 +2153,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); - await readerService.MarkChaptersUntilAsRead(user, 1, 5); + await _readerService.MarkChaptersUntilAsRead(user, 1, 5); await _context.SaveChangesAsync(); // Validate correct chapters have read status @@ -2194,10 +2195,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); - await readerService.MarkChaptersUntilAsRead(user, 1, 2.5f); + await _readerService.MarkChaptersUntilAsRead(user, 1, 2.5f); await _context.SaveChangesAsync(); // Validate correct chapters have read status @@ -2236,10 +2237,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); - await readerService.MarkChaptersUntilAsRead(user, 1, 2); + await _readerService.MarkChaptersUntilAsRead(user, 1, 2); await _context.SaveChangesAsync(); // Validate correct chapters have read status @@ -2289,12 +2290,12 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); const int markReadUntilNumber = 47; - await readerService.MarkChaptersUntilAsRead(user, 1, markReadUntilNumber); + await _readerService.MarkChaptersUntilAsRead(user, 1, markReadUntilNumber); await _context.SaveChangesAsync(); var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(1, 1); @@ -2347,9 +2348,9 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - await readerService.MarkSeriesAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1); + + await _readerService.MarkSeriesAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1); await _context.SaveChangesAsync(); Assert.Equal(4, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); @@ -2386,15 +2387,15 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(1)).ToList(); - await readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); + await _readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); await _context.SaveChangesAsync(); Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); - await readerService.MarkSeriesAsUnread(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1); + await _readerService.MarkSeriesAsUnread(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1); await _context.SaveChangesAsync(); var progresses = (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses; @@ -2476,10 +2477,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); - await readerService.MarkVolumesUntilAsRead(user, 1, 2002); + await _readerService.MarkVolumesUntilAsRead(user, 1, 2002); await _context.SaveChangesAsync(); // Validate loose leaf chapters don't get marked as read @@ -2534,10 +2535,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); - await readerService.MarkVolumesUntilAsRead(user, 1, 2002); + await _readerService.MarkVolumesUntilAsRead(user, 1, 2002); await _context.SaveChangesAsync(); // Validate loose leaf chapters don't get marked as read @@ -2577,9 +2578,9 @@ public class ReaderServiceTests new [] {"0,0", "1,1", "2,1", "3,3", "4,3", "5,5", "6,6", "7,6", "8,8", "9,9"})] public void GetPairs_ShouldReturnPairsForNoWideImages(string caseName, IList wides, IList expectedPairs) { - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var files = wides.Select((b, i) => new FileDimensionDto() {PageNumber = i, Height = 1, Width = 1, FileName = string.Empty, IsWide = b}).ToList(); - var pairs = readerService.GetPairs(files); + var pairs = _readerService.GetPairs(files); var expectedDict = new Dictionary(); foreach (var pair in expectedPairs) { diff --git a/API.Tests/Services/ReadingListServiceTests.cs b/API.Tests/Services/ReadingListServiceTests.cs index b76aee059..a2d5291fe 100644 --- a/API.Tests/Services/ReadingListServiceTests.cs +++ b/API.Tests/Services/ReadingListServiceTests.cs @@ -500,7 +500,8 @@ public class ReadingListServiceTests Assert.Equal(3, readingList.Items.Count); var readerService = new ReaderService(_unitOfWork, Substitute.For>(), - Substitute.For()); + Substitute.For(), Substitute.For(), + new DirectoryService(Substitute.For>(), new MockFileSystem())); // Mark 2 as fully read await readerService.MarkChaptersAsRead(user, 1, (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(new List() {2})).ToList()); diff --git a/API.Tests/Services/TachiyomiServiceTests.cs b/API.Tests/Services/TachiyomiServiceTests.cs index ffa784da6..33088997e 100644 --- a/API.Tests/Services/TachiyomiServiceTests.cs +++ b/API.Tests/Services/TachiyomiServiceTests.cs @@ -27,6 +27,8 @@ public class TachiyomiServiceTests private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; private readonly DataContext _context; + private readonly ReaderService _readerService; + private readonly TachiyomiService _tachiyomiService; private const string CacheDirectory = "C:/kavita/config/cache/"; private const string CoverImageDirectory = "C:/kavita/config/covers/"; private const string BackupDirectory = "C:/kavita/config/backups/"; @@ -44,6 +46,10 @@ public class TachiyomiServiceTests _mapper = config.CreateMapper(); _unitOfWork = new UnitOfWork(_context, _mapper, null); + _readerService = new ReaderService(_unitOfWork, Substitute.For>(), + Substitute.For(), Substitute.For(), + new DirectoryService(Substitute.For>(), new MockFileSystem())); + _tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), _readerService); } @@ -151,10 +157,7 @@ public class TachiyomiServiceTests }); await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), readerService); - - var latestChapter = await tachiyomiService.GetLatestChapter(1, 1); + var latestChapter = await _tachiyomiService.GetLatestChapter(1, 1); Assert.Null(latestChapter); } @@ -201,16 +204,14 @@ public class TachiyomiServiceTests }); await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), readerService); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); - await readerService.MarkSeriesAsRead(user,1); + await _readerService.MarkSeriesAsRead(user,1); await _context.SaveChangesAsync(); - var latestChapter = await tachiyomiService.GetLatestChapter(1, 1); + var latestChapter = await _tachiyomiService.GetLatestChapter(1, 1); Assert.Equal("96", latestChapter.Number); } @@ -257,16 +258,14 @@ public class TachiyomiServiceTests }); await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), readerService); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); - await tachiyomiService.MarkChaptersUntilAsRead(user,1,21); + await _tachiyomiService.MarkChaptersUntilAsRead(user,1,21); await _context.SaveChangesAsync(); - var latestChapter = await tachiyomiService.GetLatestChapter(1, 1); + var latestChapter = await _tachiyomiService.GetLatestChapter(1, 1); Assert.Equal("21", latestChapter.Number); } @@ -312,17 +311,15 @@ public class TachiyomiServiceTests }); await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), readerService); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); - await tachiyomiService.MarkChaptersUntilAsRead(user,1,1/10_000F); + await _tachiyomiService.MarkChaptersUntilAsRead(user,1,1/10_000F); await _context.SaveChangesAsync(); - var latestChapter = await tachiyomiService.GetLatestChapter(1, 1); + var latestChapter = await _tachiyomiService.GetLatestChapter(1, 1); Assert.Equal("0.0001", latestChapter.Number); } @@ -362,17 +359,15 @@ public class TachiyomiServiceTests }); await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), readerService); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); - await readerService.MarkSeriesAsRead(user, 1); + await _readerService.MarkSeriesAsRead(user, 1); await _context.SaveChangesAsync(); - var latestChapter = await tachiyomiService.GetLatestChapter(1, 1); + var latestChapter = await _tachiyomiService.GetLatestChapter(1, 1); Assert.Equal("0.0003", latestChapter.Number); } @@ -417,17 +412,14 @@ public class TachiyomiServiceTests }); await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), readerService); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); - await tachiyomiService.MarkChaptersUntilAsRead(user,1,2002/10_000F); + await _tachiyomiService.MarkChaptersUntilAsRead(user,1,2002/10_000F); await _context.SaveChangesAsync(); - var latestChapter = await tachiyomiService.GetLatestChapter(1, 1); + var latestChapter = await _tachiyomiService.GetLatestChapter(1, 1); Assert.Equal("0.2002", latestChapter.Number); } @@ -478,10 +470,7 @@ public class TachiyomiServiceTests }); await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), readerService); - - var latestChapter = await tachiyomiService.GetLatestChapter(1, 1); + var latestChapter = await _tachiyomiService.GetLatestChapter(1, 1); Assert.Null(latestChapter); } @@ -527,16 +516,13 @@ public class TachiyomiServiceTests }); await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), readerService); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); - await readerService.MarkSeriesAsRead(user,1); + await _readerService.MarkSeriesAsRead(user,1); await _context.SaveChangesAsync(); - var latestChapter = await tachiyomiService.GetLatestChapter(1, 1); + var latestChapter = await _tachiyomiService.GetLatestChapter(1, 1); Assert.Equal("96", latestChapter.Number); } @@ -583,16 +569,13 @@ public class TachiyomiServiceTests }); await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), readerService); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); - await tachiyomiService.MarkChaptersUntilAsRead(user,1,21); + await _tachiyomiService.MarkChaptersUntilAsRead(user,1,21); await _context.SaveChangesAsync(); - var latestChapter = await tachiyomiService.GetLatestChapter(1, 1); + var latestChapter = await _tachiyomiService.GetLatestChapter(1, 1); Assert.Equal("21", latestChapter.Number); } @@ -637,17 +620,14 @@ public class TachiyomiServiceTests }); await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); - var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), readerService); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); - await tachiyomiService.MarkChaptersUntilAsRead(user,1,1/10_000F); + await _tachiyomiService.MarkChaptersUntilAsRead(user,1,1/10_000F); await _context.SaveChangesAsync(); - var latestChapter = await tachiyomiService.GetLatestChapter(1, 1); + var latestChapter = await _tachiyomiService.GetLatestChapter(1, 1); Assert.Equal("0.0001", latestChapter.Number); } diff --git a/API.Tests/Services/WordCountAnalysisTests.cs b/API.Tests/Services/WordCountAnalysisTests.cs index c9e178665..39ae8848b 100644 --- a/API.Tests/Services/WordCountAnalysisTests.cs +++ b/API.Tests/Services/WordCountAnalysisTests.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; using System.IO; +using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; using API.Entities; @@ -26,7 +28,8 @@ public class WordCountAnalysisTests : AbstractDbTest public WordCountAnalysisTests() : base() { _readerService = new ReaderService(_unitOfWork, Substitute.For>(), - Substitute.For()); + Substitute.For(), Substitute.For(), + new DirectoryService(Substitute.For>(), new MockFileSystem())); } protected override async Task ResetDb() diff --git a/API/API.csproj b/API/API.csproj index 0ddae489f..46d180742 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -67,15 +67,15 @@ - - - + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + @@ -92,14 +92,14 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 5a6bf520e..badcd0eff 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -13,6 +13,7 @@ using API.Entities; using API.Entities.Enums; using API.Errors; using API.Extensions; +using API.Middleware.RateLimit; using API.Services; using API.SignalR; using AutoMapper; @@ -22,6 +23,7 @@ using Kavita.Common.EnvironmentInfo; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -769,6 +771,7 @@ public class AccountController : BaseApiController /// [AllowAnonymous] [HttpPost("forgot-password")] + [EnableRateLimiting("Authentication")] public async Task> ForgotPassword([FromQuery] string email) { var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email); @@ -847,6 +850,7 @@ public class AccountController : BaseApiController /// /// [HttpPost("resend-confirmation-email")] + [EnableRateLimiting("Authentication")] public async Task> ResendConfirmationSendEmail([FromQuery] int userId) { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index b6d3cb220..2a49c5a29 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -904,8 +904,11 @@ public class OpdsController : BaseApiController var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg", $"{Prefix}{apiKey}/image?libraryId={libraryId}&seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}"); link.TotalPages = mangaFile.Pages; - link.LastRead = progress.PageNum; - link.LastReadDate = progress.LastModifiedUtc; + if (progress != null) + { + link.LastRead = progress.PageNum; + link.LastReadDate = progress.LastModifiedUtc; + } link.IsPageStream = true; return link; } diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 469b69118..1581b60b2 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -34,12 +34,14 @@ public class ReaderController : BaseApiController private readonly IBookmarkService _bookmarkService; private readonly IAccountService _accountService; private readonly IEventHub _eventHub; + private readonly IImageService _imageService; + private readonly IDirectoryService _directoryService; /// public ReaderController(ICacheService cacheService, IUnitOfWork unitOfWork, ILogger logger, IReaderService readerService, IBookmarkService bookmarkService, - IAccountService accountService, IEventHub eventHub) + IAccountService accountService, IEventHub eventHub, IImageService imageService, IDirectoryService directoryService) { _cacheService = cacheService; _unitOfWork = unitOfWork; @@ -48,6 +50,8 @@ public class ReaderController : BaseApiController _bookmarkService = bookmarkService; _accountService = accountService; _eventHub = eventHub; + _imageService = imageService; + _directoryService = directoryService; } /// @@ -114,6 +118,20 @@ public class ReaderController : BaseApiController } } + [HttpGet("thumbnail")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)] + [AllowAnonymous] + public async Task GetThumbnail(int chapterId, int pageNum) + { + var chapter = await _cacheService.Ensure(chapterId, true); + if (chapter == null) return BadRequest("There was an issue extracting images from chapter"); + var images = _cacheService.GetCachedPages(chapterId); + + var path = await _readerService.GetThumbnail(chapter, pageNum, images); + var format = Path.GetExtension(path).Replace(".", string.Empty); // TODO: Make this an extension + return PhysicalFile(path, "image/" + format, Path.GetFileName(path), true); + } + /// /// Returns an image for a given bookmark series. Side effect: This will cache the bookmark images for reading. /// @@ -172,13 +190,14 @@ public class ReaderController : BaseApiController /// /// Returns various information about a Chapter. Side effect: This will cache the chapter images for reading. /// + /// This is generally the first call when attempting to read to allow pre-generation of assets needed for reading /// /// Should Kavita extract pdf into images. Defaults to false. /// Include file dimensions. Only useful for image based reading /// [HttpGet("chapter-info")] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"chapterId", "extractPdf", "includeDimensions"})] - public async Task> GetChapterInfo(int chapterId, bool extractPdf = false, bool includeDimensions = false) + public async Task> GetChapterInfo(int chapterId, bool extractPdf = false, bool includeDimensions = false) { if (chapterId <= 0) return Ok(null); // This can happen occasionally from UI, we should just ignore var chapter = await _cacheService.Ensure(chapterId, extractPdf); diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 1e79d57d2..75e5f8b7d 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -47,6 +47,7 @@ public sealed class DataContext : IdentityDbContext FolderPath { get; set; } = null!; public DbSet Device { get; set; } = null!; public DbSet ServerStatistics { get; set; } = null!; + public DbSet SecurityEvent { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) diff --git a/API/Data/Migrations/20230316123908_SecurityEvent.Designer.cs b/API/Data/Migrations/20230316123908_SecurityEvent.Designer.cs new file mode 100644 index 000000000..e0c1b3bfb --- /dev/null +++ b/API/Data/Migrations/20230316123908_SecurityEvent.Designer.cs @@ -0,0 +1,1901 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20230316123908_SecurityEvent")] + partial class SecurityEvent + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInDashboard") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInRecommended") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.SecurityEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("RequestMethod") + .HasColumnType("TEXT"); + + b.Property("RequestPath") + .HasColumnType("TEXT"); + + b.Property("UserAgent") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SecurityEvent"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + 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") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Devices"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20230316123908_SecurityEvent.cs b/API/Data/Migrations/20230316123908_SecurityEvent.cs new file mode 100644 index 000000000..ec4eab520 --- /dev/null +++ b/API/Data/Migrations/20230316123908_SecurityEvent.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class SecurityEvent : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SecurityEvent", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + IpAddress = table.Column(type: "TEXT", nullable: true), + RequestMethod = table.Column(type: "TEXT", nullable: true), + RequestPath = table.Column(type: "TEXT", nullable: true), + UserAgent = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SecurityEvent", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SecurityEvent"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index a06bf1979..e52f1ef64 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "7.0.3"); + modelBuilder.HasAnnotation("ProductVersion", "7.0.4"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -944,6 +944,35 @@ namespace API.Data.Migrations b.ToTable("ReadingListItem"); }); + modelBuilder.Entity("API.Entities.SecurityEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("RequestMethod") + .HasColumnType("TEXT"); + + b.Property("RequestPath") + .HasColumnType("TEXT"); + + b.Property("UserAgent") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SecurityEvent"); + }); + modelBuilder.Entity("API.Entities.Series", b => { b.Property("Id") diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index 12f98e565..83384f8fa 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -47,6 +47,7 @@ public interface IReadingListRepository IEnumerable GetReadingListCharactersAsync(int readingListId); Task> GetAllWithNonWebPCovers(); Task> GetFirstFourCoverImagesByReadingListId(int readingListId); + Task RemoveReadingListsWithoutSeries(); } public class ReadingListRepository : IReadingListRepository @@ -132,6 +133,18 @@ public class ReadingListRepository : IReadingListRepository .ToListAsync(); } + public async Task RemoveReadingListsWithoutSeries() + { + var listsToDelete = await _context.ReadingList + .Include(c => c.Items) + .Where(c => c.Items.Count == 0) + .AsSplitQuery() + .ToListAsync(); + _context.RemoveRange(listsToDelete); + + return await _context.SaveChangesAsync(); + } + public void Remove(ReadingListItem item) { _context.ReadingListItem.Remove(item); diff --git a/API/Data/Repositories/SecurityEventRepository.cs b/API/Data/Repositories/SecurityEventRepository.cs new file mode 100644 index 000000000..f89ddd9c5 --- /dev/null +++ b/API/Data/Repositories/SecurityEventRepository.cs @@ -0,0 +1,27 @@ +using API.Entities; +using AutoMapper; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories; + +public interface ISecurityEventRepository +{ + void Add(SecurityEvent securityEvent); +} + +public class SecurityEventRepository : ISecurityEventRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public SecurityEventRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Add(SecurityEvent securityEvent) + { + _context.SecurityEvent.Add(securityEvent); + } +} diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index 02a089eca..742fd790d 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -25,6 +25,7 @@ public interface IUnitOfWork ISiteThemeRepository SiteThemeRepository { get; } IMangaFileRepository MangaFileRepository { get; } IDeviceRepository DeviceRepository { get; } + ISecurityEventRepository SecurityEventRepository { get; } bool Commit(); Task CommitAsync(); bool HasChanges(); @@ -62,6 +63,7 @@ public class UnitOfWork : IUnitOfWork public ISiteThemeRepository SiteThemeRepository => new SiteThemeRepository(_context, _mapper); public IMangaFileRepository MangaFileRepository => new MangaFileRepository(_context); public IDeviceRepository DeviceRepository => new DeviceRepository(_context, _mapper); + public ISecurityEventRepository SecurityEventRepository => new SecurityEventRepository(_context, _mapper); /// /// Commits changes to the DB. Completes the open transaction. diff --git a/API/Entities/SecurityEvent.cs b/API/Entities/SecurityEvent.cs new file mode 100644 index 000000000..f56466519 --- /dev/null +++ b/API/Entities/SecurityEvent.cs @@ -0,0 +1,14 @@ +using System; + +namespace API.Entities; + +public class SecurityEvent +{ + public int Id { get; set; } + public string IpAddress { get; set; } + public string RequestMethod { get; set; } + public string RequestPath { get; set; } + public string UserAgent { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime CreatedAtUtc { get; set; } +} diff --git a/API/Helpers/PersonHelper.cs b/API/Helpers/PersonHelper.cs index 606b3b3db..cd63ced15 100644 --- a/API/Helpers/PersonHelper.cs +++ b/API/Helpers/PersonHelper.cs @@ -7,6 +7,7 @@ using API.DTOs; using API.Entities; using API.Entities.Enums; using API.Extensions; +using API.Helpers.Builders; namespace API.Helpers; @@ -156,7 +157,7 @@ public static class PersonHelper else { // Add new tag - handleAdd(DbFactory.Person(tag.Name, role)); + handleAdd(new PersonBuilder(tag.Name, role).Build()); isModified = true; } } diff --git a/API/Logging/LogLevelOptions.cs b/API/Logging/LogLevelOptions.cs index a175c0978..092c71aff 100644 --- a/API/Logging/LogLevelOptions.cs +++ b/API/Logging/LogLevelOptions.cs @@ -1,6 +1,7 @@ using Serilog; using Serilog.Core; using Serilog.Events; +using Serilog.Filters; using Serilog.Formatting.Display; namespace API.Logging; @@ -12,6 +13,7 @@ namespace API.Logging; public static class LogLevelOptions { public const string LogFile = "config/logs/kavita.log"; + public const string SecurityLogFile = "config/logs/security.log"; public const bool LogRollingEnabled = true; /// /// Controls the Logging Level of the Application diff --git a/API/Middleware/RateLimit/AuthenticationRateLimiterPolicy.cs b/API/Middleware/RateLimit/AuthenticationRateLimiterPolicy.cs new file mode 100644 index 000000000..f7732b7bd --- /dev/null +++ b/API/Middleware/RateLimit/AuthenticationRateLimiterPolicy.cs @@ -0,0 +1,36 @@ +using System; +using System.Globalization; +using System.Threading; +using System.Threading.RateLimiting; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; + +namespace API.Middleware.RateLimit; + +public class AuthenticationRateLimiterPolicy : IRateLimiterPolicy +{ + public RateLimitPartition GetPartition(HttpContext httpContext) + { + return RateLimitPartition.GetFixedWindowLimiter(httpContext.Request.Headers.Host.ToString(), + partition => new FixedWindowRateLimiterOptions + { + AutoReplenishment = true, + PermitLimit = 1, + Window = TimeSpan.FromMinutes(10), + }); + } + + public Func? OnRejected { get; } = + (context, _) => + { + if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) + { + context.HttpContext.Response.Headers.RetryAfter = + ((int) retryAfter.TotalSeconds).ToString(NumberFormatInfo.InvariantInfo); + } + + context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + return new ValueTask(); + }; +} diff --git a/API/Middleware/SecurityEventMiddleware.cs b/API/Middleware/SecurityEventMiddleware.cs new file mode 100644 index 000000000..ac47f554c --- /dev/null +++ b/API/Middleware/SecurityEventMiddleware.cs @@ -0,0 +1,62 @@ +using System; +using System.IO; +using System.Security.AccessControl; +using System.Threading.Tasks; +using API.Data; +using API.DTOs; +using API.Entities; +using API.Logging; +using API.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Core; +using ILogger = Serilog.ILogger; + +namespace API.Middleware; + +public class SecurityEventMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public SecurityEventMiddleware(RequestDelegate next) + { + _next = next; + + _logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.File(Path.Join(Directory.GetCurrentDirectory(), "config/logs/", "security.log"), rollingInterval: RollingInterval.Day) + .CreateLogger(); + } + + public async Task InvokeAsync(HttpContext context) + { + var ipAddress = context.Connection.RemoteIpAddress?.ToString(); + var requestMethod = context.Request.Method; + var requestPath = context.Request.Path; + var userAgent = context.Request.Headers["User-Agent"]; + + var securityEvent = new SecurityEvent + { + IpAddress = ipAddress, + RequestMethod = requestMethod, + RequestPath = requestPath, + UserAgent = userAgent, + CreatedAt = DateTime.Now, + CreatedAtUtc = DateTime.UtcNow, + }; + + using (var scope = context.RequestServices.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Add(securityEvent); + await dbContext.SaveChangesAsync(); + _logger.Debug("Request Processed: {@SecurityEvent}", securityEvent); + } + + + await _next(context); + } +} diff --git a/API/Services/BookmarkService.cs b/API/Services/BookmarkService.cs index 9f2a0794e..c3b450214 100644 --- a/API/Services/BookmarkService.cs +++ b/API/Services/BookmarkService.cs @@ -76,7 +76,8 @@ public class BookmarkService : IBookmarkService /// /// This is a job that runs after a bookmark is saved /// - private async Task ConvertBookmarkToWebP(int bookmarkId) + /// This must be public + public async Task ConvertBookmarkToWebP(int bookmarkId) { var bookmarkDirectory = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index 8ec263404..128157d31 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -32,6 +32,7 @@ public interface ICacheService void CleanupChapters(IEnumerable chapterIds); void CleanupBookmarks(IEnumerable seriesIds); string GetCachedPagePath(int chapterId, int page); + IEnumerable GetCachedPages(int chapterId); IEnumerable GetCachedFileDimensions(int chapterId); string GetCachedBookmarkPagePath(int seriesId, int page); string GetCachedFile(Chapter chapter); @@ -58,6 +59,13 @@ public class CacheService : ICacheService _bookmarkService = bookmarkService; } + public IEnumerable GetCachedPages(int chapterId) + { + var path = GetCachePath(chapterId); + return _directoryService.GetFilesWithExtension(path, Tasks.Scanner.Parser.Parser.ImageFileExtensions) + .OrderByNatural(Path.GetFileNameWithoutExtension); + } + public IEnumerable GetCachedFileDimensions(int chapterId) { var sw = Stopwatch.StartNew(); @@ -276,15 +284,7 @@ public class CacheService : ICacheService .OrderByNatural(Path.GetFileNameWithoutExtension) .ToArray(); - if (files.Length == 0) - { - return string.Empty; - } - - if (page > files.Length) page = files.Length; - - // Since array is 0 based, we need to keep that in account (only affects last image) - return page == files.Length ? files.ElementAt(page - 1) : files.ElementAt(page); + return GetPageFromFiles(files, page); } public async Task CacheBookmarkForSeries(int userId, int seriesId) @@ -310,4 +310,33 @@ public class CacheService : ICacheService _directoryService.ClearAndDeleteDirectory(destDirectory); } + + /// + /// Returns either the file or an empty string + /// + /// + /// + /// + public static string GetPageFromFiles(string[] files, int pageNum) + { + files = files + .AsEnumerable() + .OrderByNatural(Path.GetFileNameWithoutExtension) + .ToArray(); + + if (files.Length == 0) + { + return string.Empty; + } + + if (pageNum < 0) + { + pageNum = 0; + } + + // Since array is 0 based, we need to keep that in account (only affects last image) + return pageNum >= files.Length ? files.ElementAt(Math.Min(pageNum - 1, files.Length - 1)) : files.ElementAt(pageNum); + } + + } diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 9f477d36e..33c8f3bea 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -22,9 +22,25 @@ public interface IImageService /// Width of thumbnail /// File name with extension of the file. This will always write to string CreateThumbnailFromBase64(string encodedImage, string fileName, bool saveAsWebP = false, int thumbnailWidth = 320); - + /// + /// Writes out a thumbnail by stream input + /// + /// + /// + /// + /// + /// string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, bool saveAsWebP = false); /// + /// Writes out a thumbnail by file path input + /// + /// + /// + /// + /// + /// + string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, bool saveAsWebP = false); + /// /// Converts the passed image to webP and outputs it in the same directory /// /// Full path to the image to convert @@ -115,6 +131,19 @@ public class ImageService : IImageService return filename; } + public string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, bool saveAsWebP = false) + { + using var thumbnail = Image.Thumbnail(sourceFile, ThumbnailWidth); + var filename = fileName + (saveAsWebP ? ".webp" : ".png"); + _directoryService.ExistOrCreate(outputDirectory); + try + { + _directoryService.FileSystem.File.Delete(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); + } catch (Exception) {/* Swallow exception */} + thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); + return filename; + } + public Task ConvertToWebP(string filePath, string outputPath) { var file = _directoryService.FileSystem.FileInfo.New(filePath); @@ -218,6 +247,16 @@ public class ImageService : IImageService return $"readinglist{readingListId}"; } + /// + /// Returns the name format for a thumbnail (temp thumbnail) + /// + /// + /// + public static string GetThumbnailFormat(int chapterId) + { + return $"thumbnail{chapterId}"; + } + public static string CreateMergedImage(List coverImages, string dest) { diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index 8a2d1ab0a..fb1a91709 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; @@ -33,6 +34,7 @@ public interface IReaderService Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber); HourEstimateRangeDto GetTimeEstimate(long wordCount, int pageCount, bool isEpub); IDictionary GetPairs(IEnumerable dimensions); + Task GetThumbnail(Chapter chapter, int pageNum, IEnumerable cachedImages); } public class ReaderService : IReaderService @@ -40,6 +42,8 @@ public class ReaderService : IReaderService private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IEventHub _eventHub; + private readonly IImageService _imageService; + private readonly IDirectoryService _directoryService; private readonly ChapterSortComparer _chapterSortComparer = ChapterSortComparer.Default; private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = ChapterSortComparerZeroFirst.Default; @@ -48,14 +52,17 @@ public class ReaderService : IReaderService public const float AvgWordsPerHour = (MaxWordsPerHour + MinWordsPerHour) / 2F; private const float MinPagesPerMinute = 3.33F; private const float MaxPagesPerMinute = 2.75F; - public const float AvgPagesPerMinute = (MaxPagesPerMinute + MinPagesPerMinute) / 2F; + public const float AvgPagesPerMinute = (MaxPagesPerMinute + MinPagesPerMinute) / 2F; //3.04 - public ReaderService(IUnitOfWork unitOfWork, ILogger logger, IEventHub eventHub) + public ReaderService(IUnitOfWork unitOfWork, ILogger logger, IEventHub eventHub, IImageService imageService, + IDirectoryService directoryService) { _unitOfWork = unitOfWork; _logger = logger; _eventHub = eventHub; + _imageService = imageService; + _directoryService = directoryService; } public static string FormatBookmarkFolderPath(string baseDirectory, int userId, int seriesId, int chapterId) @@ -644,6 +651,44 @@ public class ReaderService : IReaderService return pairs; } + /// + /// + /// + /// + /// + /// + /// Full path of thumbnail + public async Task GetThumbnail(Chapter chapter, int pageNum, IEnumerable cachedImages) + { + var outputDirectory = + _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, ImageService.GetThumbnailFormat(chapter.Id)); + try + { + var saveAsWebp = + (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertBookmarkToWebP; + + if (!Directory.Exists(outputDirectory)) + { + var outputtedThumbnails = cachedImages + .Select((img, idx) => + _directoryService.FileSystem.Path.Join(outputDirectory, + _imageService.WriteCoverThumbnail(img, $"{idx}", outputDirectory, saveAsWebp))) + .ToArray(); + return CacheService.GetPageFromFiles(outputtedThumbnails, pageNum); + } + + var files = _directoryService.GetFilesWithExtension(outputDirectory, + Tasks.Scanner.Parser.Parser.ImageFileExtensions); + return CacheService.GetPageFromFiles(files, pageNum); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an error when trying to get thumbnail for Chapter {ChapterId}, Page {PageNum}", chapter.Id, pageNum); + _directoryService.ClearAndDeleteDirectory(outputDirectory); + throw; + } + } + /// /// Formats a Chapter name based on the library it's in /// @@ -668,4 +713,6 @@ public class ReaderService : IReaderService throw new ArgumentOutOfRangeException(nameof(libraryType), libraryType, null); } } + + } diff --git a/API/Services/ReadingListService.cs b/API/Services/ReadingListService.cs index f2c4f6893..03f03eae9 100644 --- a/API/Services/ReadingListService.cs +++ b/API/Services/ReadingListService.cs @@ -509,7 +509,7 @@ public class ReadingListService : IReadingListService var allReadingLists = (user.ReadingLists).ToDictionary(s => s.NormalizedTitle); if (!allReadingLists.TryGetValue(readingListNameNormalized, out var readingList)) { - readingList = DbFactory.ReadingList(cblReading.Name, string.Empty, false); + readingList = DbFactory.ReadingList(cblReading.Name, cblReading.Summary, false); user.ReadingLists.Add(readingList); } else diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index 48d75cb18..253df6b49 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -103,7 +103,6 @@ public class StatisticService : IStatisticService }) .ToListAsync(); - var averageReadingTimePerWeek = _context.AppUserProgresses .Where(p => p.AppUserId == userId) .Join(_context.Chapter, p => p.ChapterId, c => c.Id, @@ -112,7 +111,7 @@ public class StatisticService : IStatisticService AverageReadingHours = Math.Min((float) p.PagesRead / (float) c.Pages, 1.0) * ((float) c.AvgHoursToRead) }) .Select(x => x.AverageReadingHours) - .Average() / 7.0; + .Average() * 7.0; return new UserReadStatistics() { diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index 44d28065b..9fa8a38da 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -58,6 +58,17 @@ public class CleanupService : ICleanupService [AutomaticRetry(Attempts = 3, LogEvents = false, OnAttemptsExceeded = AttemptsExceededAction.Fail)] public async Task Cleanup() { + if (TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllCoverToWebP", Array.Empty(), + TaskScheduler.DefaultQueue, true) || + TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllBookmarkToWebP", Array.Empty(), + TaskScheduler.DefaultQueue, true)) + { + _logger.LogInformation("Cleanup put on hold as a conversion to WebP in progress"); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.ErrorEvent("Cleanup", "Cleanup put on hold as a conversion to WebP in progress")); + return; + } + _logger.LogInformation("Starting Cleanup"); await SendProgress(0F, "Starting cleanup"); _logger.LogInformation("Cleaning temp directory"); @@ -90,6 +101,7 @@ public class CleanupService : ICleanupService await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated(); await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated(); await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); + await _unitOfWork.ReadingListRepository.RemoveReadingListsWithoutSeries(); } private async Task SendProgress(float progress, string subtitle) diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 177f35e97..6777e052b 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -225,6 +225,11 @@ public static class Parser new Regex( @"(?.+?):? (\b|_|-)(vol)\.?(\s|-|_)?\d+", MatchOptions, RegexTimeout), + // [xPearse] Kyochuu Rettou Chapter 001 Volume 1 [English] [Manga] [Volume Scans] + new Regex( + @"(?.+?):?(\s|\b|_|-)Chapter(\s|\b|_|-)\d+(\s|\b|_|-)(vol)(ume)", + MatchOptions, + RegexTimeout), // [xPearse] Kyochuu Rettou Volume 1 [English] [Manga] [Volume Scans] new Regex( @"(?.+?):? (\b|_|-)(vol)(ume)", diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index 184b1d06d..1e6fa5baa 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -51,6 +51,9 @@ public class ProcessSeries : IProcessSeries private IList _people; private Dictionary _tags; private Dictionary _collectionTags; + private readonly object _peopleLock; + private readonly object _genreLock; + private readonly object _tagLock; public ProcessSeries(IUnitOfWork unitOfWork, ILogger logger, IEventHub eventHub, IDirectoryService directoryService, ICacheHelper cacheHelper, IReadingItemService readingItemService, @@ -838,7 +841,7 @@ public class ProcessSeries : IProcessSeries if (person == null) { person = DbFactory.Person(name, role); - lock (_people) + lock (_peopleLock) { _people.Add(person); } @@ -865,7 +868,7 @@ public class ProcessSeries : IProcessSeries if (newTag) { genre = DbFactory.Genre(name); - lock (_genres) + lock (_genreLock) { _genres.Add(normalizedName, genre); _unitOfWork.GenreRepository.Attach(genre); @@ -894,7 +897,7 @@ public class ProcessSeries : IProcessSeries if (tag == null) { tag = DbFactory.Tag(name); - lock (_tags) + lock (_tagLock) { _tags.Add(normalizedName, tag); } diff --git a/API/Startup.cs b/API/Startup.cs index 6f65e3a05..6910fc5ef 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Net; using System.Net.Sockets; using System.Reflection; +using System.Threading.RateLimiting; using System.Threading.Tasks; using API.Constants; using API.Data; @@ -14,6 +15,7 @@ using API.Entities.Enums; using API.Extensions; using API.Logging; using API.Middleware; +using API.Middleware.RateLimit; using API.Services; using API.Services.HostedServices; using API.Services.Tasks; @@ -179,6 +181,19 @@ public class Startup services.AddResponseCaching(); + services.AddRateLimiter(options => + { + options.AddPolicy("Authentication", httpContext => + new AuthenticationRateLimiterPolicy().GetPartition(httpContext)); + // RateLimitPartition.GetFixedWindowLimiter(httpContext.Connection.RemoteIpAddress?.ToString(), + // partition => new FixedWindowRateLimiterOptions + // { + // AutoReplenishment = true, + // PermitLimit = 1, + // Window = TimeSpan.FromMinutes(1), + // })); + }); + services.AddHangfire(configuration => configuration .UseSimpleAssemblyNameTypeSerializer() .UseRecommendedSerializerSettings() @@ -259,6 +274,7 @@ public class Startup app.UseMiddleware(); + app.UseMiddleware(); if (env.IsDevelopment()) { @@ -278,10 +294,16 @@ public class Startup app.UseForwardedHeaders(); - var basePath = Configuration.BaseUrl; + app.UseRateLimiter(); + var basePath = Configuration.BaseUrl; app.UsePathBase(basePath); - UpdateBaseUrlInIndex(basePath); + if (!env.IsDevelopment()) + { + // We don't update the index.html in local as we don't serve from there + UpdateBaseUrlInIndex(basePath); + } + app.UseRouting(); @@ -292,7 +314,17 @@ public class Startup .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials() // For SignalR token query param - .WithOrigins("http://localhost:4200", $"http://{GetLocalIpAddress()}:4200", $"http://{GetLocalIpAddress()}:5000") + .WithOrigins("http://localhost:4200", $"http://{GetLocalIpAddress()}:4200", $"http://{GetLocalIpAddress()}:5000", "https://kavita.majora2007.duckdns.org") + .WithExposedHeaders("Content-Disposition", "Pagination")); + } + else + { + // Allow CORS for Kavita's url + app.UseCors(policy => policy + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials() // For SignalR token query param + .WithOrigins("https://kavita.majora2007.duckdns.org") .WithExposedHeaders("Content-Disposition", "Pagination")); } @@ -311,6 +343,7 @@ public class Startup OnPrepareResponse = ctx => { ctx.Context.Response.Headers[HeaderNames.CacheControl] = "public,max-age=" + TimeSpan.FromHours(24); + ctx.Context.Response.Headers["X-Robots-Tag"] = "noindex,nofollow"; } }); @@ -326,7 +359,7 @@ public class Startup new[] { "Accept-Encoding" }; // Don't let the site be iframed outside the same origin (clickjacking) - context.Response.Headers.XFrameOptions = "SAMEORIGIN"; + context.Response.Headers.XFrameOptions = Configuration.XFrameOptions; // Setup CSP to ensure we load assets only from these origins context.Response.Headers.Add("Content-Security-Policy", "frame-ancestors 'none';"); @@ -359,19 +392,26 @@ public class Startup }); var _logger = serviceProvider.GetRequiredService>(); - _logger.LogInformation("Starting with base url as {baseUrl}", basePath); + _logger.LogInformation("Starting with base url as {BaseUrl}", basePath); } private static void UpdateBaseUrlInIndex(string baseUrl) { - if (new OsInfo(Array.Empty()).IsDocker) return; - var htmlDoc = new HtmlDocument(); - var indexHtmlPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "index.html"); - htmlDoc.Load(indexHtmlPath); + try + { + if (new OsInfo(Array.Empty()).IsDocker) return; + var htmlDoc = new HtmlDocument(); + var indexHtmlPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "index.html"); + htmlDoc.Load(indexHtmlPath); - var baseNode = htmlDoc.DocumentNode.SelectSingleNode("/html/head/base"); - baseNode.SetAttributeValue("href", baseUrl); - htmlDoc.Save(indexHtmlPath); + var baseNode = htmlDoc.DocumentNode.SelectSingleNode("/html/head/base"); + baseNode.SetAttributeValue("href", baseUrl); + htmlDoc.Save(indexHtmlPath); + } + catch (Exception ex) + { + Log.Error(ex, "There was an error setting base url"); + } } private static void OnShutdown() diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index 476775ca2..c788c8883 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -10,6 +10,7 @@ public static class Configuration { public const string DefaultIpAddresses = "0.0.0.0,::"; public const string DefaultBaseUrl = "/"; + public const string DefaultXFrameOptions = "SAMEORIGIN"; private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename()); public static int Port @@ -36,6 +37,8 @@ public static class Configuration set => SetBaseUrl(GetAppSettingFilename(), value); } + public static string XFrameOptions => GetXFrameOptions(GetAppSettingFilename()); + private static string GetAppSettingFilename() { if (!string.IsNullOrEmpty(AppSettingsFilename)) @@ -224,7 +227,7 @@ public static class Configuration if (jsonObj.TryGetProperty(key, out JsonElement tokenElement)) { var baseUrl = tokenElement.GetString(); - if (!String.IsNullOrEmpty(baseUrl)) + if (!string.IsNullOrEmpty(baseUrl)) { baseUrl = !baseUrl.StartsWith("/") ? $"/{baseUrl}" @@ -277,6 +280,35 @@ public static class Configuration } #endregion + #region XFrameOrigins + private static string GetXFrameOptions(string filePath) + { + if (new OsInfo(Array.Empty()).IsDocker) + { + return DefaultBaseUrl; + } + + try + { + var json = File.ReadAllText(filePath); + var jsonObj = JsonSerializer.Deserialize(json); + const string key = "XFrameOrigins"; + + if (jsonObj.TryGetProperty(key, out JsonElement tokenElement)) + { + var origins = tokenElement.GetString(); + return !string.IsNullOrEmpty(origins) ? origins : DefaultBaseUrl; + } + } + catch (Exception ex) + { + Console.WriteLine("Error reading app settings: " + ex.Message); + } + + return DefaultXFrameOptions; + } + #endregion + private class AppSettings { public string TokenKey { get; set; } diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 3ca14dc96..9c439faf3 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -14,7 +14,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index d26d7362f..aeba75783 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -34,7 +34,6 @@ "file-saver": "^2.0.5", "lazysizes": "^5.3.2", "ng-circle-progress": "^1.7.1", - "ng-swipe": "^2.0.1", "ngx-color-picker": "^13.0.0", "ngx-extended-pdf-viewer": "^15.2.2", "ngx-file-drop": "^14.0.2", @@ -5620,14 +5619,6 @@ "node": ">=8.9.0" } }, - "node_modules/ag-swipe-core": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/ag-swipe-core/-/ag-swipe-core-1.0.2.tgz", - "integrity": "sha512-NNONbrEbsmu6wsl7E07eGYVZw8Wx7hOok2TlhQLU/50EUhmI3Vpg8EDz0rWhV/HrfUAoEd4LxBvLAeT9DswQDw==", - "dependencies": { - "rxjs": "^7.5.5" - } - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -13906,19 +13897,6 @@ "rxjs": ">=6.4.0" } }, - "node_modules/ng-swipe": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ng-swipe/-/ng-swipe-2.0.1.tgz", - "integrity": "sha512-y4w2d719VK1u6KUlNqhHVevzT+yR30bnTTLkFNEsVG3Gp5+oZhUnflVNWfzIw+O8GCjZqVLelwla/jOkqUclmQ==", - "dependencies": { - "ag-swipe-core": "^1.0.0", - "tslib": "^2.3.0" - }, - "peerDependencies": { - "@angular/common": "^14.0.0", - "@angular/core": "^14.0.0" - } - }, "node_modules/ngx-color-picker": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/ngx-color-picker/-/ngx-color-picker-13.0.0.tgz", @@ -22337,14 +22315,6 @@ } } }, - "ag-swipe-core": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/ag-swipe-core/-/ag-swipe-core-1.0.2.tgz", - "integrity": "sha512-NNONbrEbsmu6wsl7E07eGYVZw8Wx7hOok2TlhQLU/50EUhmI3Vpg8EDz0rWhV/HrfUAoEd4LxBvLAeT9DswQDw==", - "requires": { - "rxjs": "^7.5.5" - } - }, "agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -28587,15 +28557,6 @@ "tslib": "^2.0.0" } }, - "ng-swipe": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ng-swipe/-/ng-swipe-2.0.1.tgz", - "integrity": "sha512-y4w2d719VK1u6KUlNqhHVevzT+yR30bnTTLkFNEsVG3Gp5+oZhUnflVNWfzIw+O8GCjZqVLelwla/jOkqUclmQ==", - "requires": { - "ag-swipe-core": "^1.0.0", - "tslib": "^2.3.0" - } - }, "ngx-color-picker": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/ngx-color-picker/-/ngx-color-picker-13.0.0.tgz", diff --git a/UI/Web/package.json b/UI/Web/package.json index 9fd596eef..e1c0b0764 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -40,7 +40,6 @@ "file-saver": "^2.0.5", "lazysizes": "^5.3.2", "ng-circle-progress": "^1.7.1", - "ng-swipe": "^2.0.1", "ngx-color-picker": "^13.0.0", "ngx-extended-pdf-viewer": "^15.2.2", "ngx-file-drop": "^14.0.2", diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 51fe7ed90..e435afd35 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -101,6 +101,10 @@ export class ReaderService { return this.baseUrl + 'reader/image?chapterId=' + chapterId + '&page=' + page; } + getThumbnailUrl(chapterId: number, page: number) { + return this.baseUrl + 'reader/thumbnail?chapterId=' + chapterId + '&page=' + page; + } + getBookmarkPageUrl(seriesId: number, apiKey: string, page: number) { return this.baseUrl + 'reader/bookmark-image?seriesId=' + seriesId + '&page=' + page + '&apiKey=' + encodeURIComponent(apiKey); } diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html index f336c4378..4f2024005 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html @@ -32,7 +32,7 @@
-
{{pageNum}}
+
{{pageNum}}
diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html index d3ac12f9e..724720cde 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html @@ -379,10 +379,12 @@
- Max Items: {{metadata.maxCount}} + Max Items: {{metadata.maxCount}} +
-
- Total Items: {{metadata.totalCount}} +
+ Total Items: {{metadata.totalCount}} +
Publication Status: {{metadata.publicationStatus | publicationStatus}}
Total Pages: {{series.pages}}
diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts index 8faf56b37..a81d1b825 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts @@ -278,6 +278,9 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.collectionTagSettings.compareFn = (options: CollectionTag[], filter: string) => { return options.filter(m => this.utilityService.filter(m.title, filter)); } + this.collectionTagSettings.compareFnForAdd = (options: CollectionTag[], filter: string) => { + return options.filter(m => this.utilityService.filterMatches(m.title, filter)); + } this.collectionTagSettings.selectionCompareFn = (a: CollectionTag, b: CollectionTag) => { return a.title === b.title; } @@ -310,6 +313,9 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.tagsSettings.selectionCompareFn = (a: Tag, b: Tag) => { return a.id == b.id; } + this.tagsSettings.compareFnForAdd = (options: Tag[], filter: string) => { + return options.filter(m => this.utilityService.filterMatches(m.title, filter)); + } if (this.metadata.tags) { this.tagsSettings.savedData = this.metadata.tags; @@ -331,6 +337,9 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.genreSettings.compareFn = (options: Genre[], filter: string) => { return options.filter(m => this.utilityService.filter(m.title, filter)); } + this.genreSettings.compareFnForAdd = (options: Genre[], filter: string) => { + return options.filter(m => this.utilityService.filterMatches(m.title, filter)); + } this.genreSettings.selectionCompareFn = (a: Genre, b: Genre) => { return a.title == b.title; } @@ -372,6 +381,9 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.languageSettings.compareFn = (options: Language[], filter: string) => { return options.filter(m => this.utilityService.filter(m.title, filter)); } + this.languageSettings.compareFnForAdd = (options: Language[], filter: string) => { + return options.filter(m => this.utilityService.filterMatches(m.title, filter)); + } this.languageSettings.fetchFn = (filter: string) => of(this.validLanguages) .pipe(map(items => this.languageSettings.compareFn(items, filter))); @@ -426,6 +438,9 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { personSettings.compareFn = (options: Person[], filter: string) => { return options.filter(m => this.utilityService.filter(m.name, filter)); } + personSettings.compareFnForAdd = (options: Person[], filter: string) => { + return options.filter(m => this.utilityService.filterMatches(m.name, filter)); + } personSettings.selectionCompareFn = (a: Person, b: Person) => { return a.name == b.name && a.role == b.role; diff --git a/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.scss b/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.scss index f2d9d0f7c..e1238ef7b 100644 --- a/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.scss +++ b/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.scss @@ -1,6 +1,8 @@ @use '../../../../manga-reader-common'; .image-container { + height: calc(100vh); // override as on single, we -34px for the potential scrollbar + #image-1 { &.double { margin: 0 0 0 auto; diff --git a/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.scss b/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.scss index f93ef2fba..e0682758f 100644 --- a/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.scss +++ b/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.scss @@ -2,6 +2,8 @@ // Overrides for reverse .image-container { + height: calc(100vh); // override as on single, we -34px for the potential scrollbar + &.reverse { overflow: unset; display: flex; diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts index 3d29a0059..0545953bb 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts @@ -31,8 +31,8 @@ import { DoubleRendererComponent } from '../double-renderer/double-renderer.comp import { DoubleReverseRendererComponent } from '../double-reverse-renderer/double-reverse-renderer.component'; import { SingleRendererComponent } from '../single-renderer/single-renderer.component'; import { ChapterInfo } from '../../_models/chapter-info'; -import { SwipeEvent } from 'ng-swipe'; import { DoubleNoCoverRendererComponent } from '../double-renderer-no-cover/double-no-cover-renderer.component'; +import { SwipeEvent } from 'src/app/ng-swipe/ag-swipe.core'; const PREFETCH_PAGES = 10; @@ -379,7 +379,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { // This is for the pagination area get MaxHeight() { if (this.FittingOption !== FITTING_OPTION.HEIGHT) { - return this.mangaReaderService.getPageDimensions(this.pageNum)?.height + 'px'; + return Math.min(this.readingArea?.nativeElement?.clientHeight, this.mangaReaderService.getPageDimensions(this.pageNum)?.height!) + 'px'; } return 'calc(var(--vh) * 100)'; } diff --git a/UI/Web/src/app/manga-reader/manga-reader.module.ts b/UI/Web/src/app/manga-reader/manga-reader.module.ts index 303e5182c..37a8bfc9c 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.module.ts +++ b/UI/Web/src/app/manga-reader/manga-reader.module.ts @@ -17,8 +17,8 @@ import { DoubleRendererComponent } from './_components/double-renderer/double-re import { DoubleReverseRendererComponent } from './_components/double-reverse-renderer/double-reverse-renderer.component'; import { MangaReaderComponent } from './_components/manga-reader/manga-reader.component'; import { FittingIconPipe } from './_pipes/fitting-icon.pipe'; -import { SwipeModule } from 'ng-swipe'; import { DoubleNoCoverRendererComponent } from './_components/double-renderer-no-cover/double-no-cover-renderer.component'; +import { NgSwipeModule } from '../ng-swipe/ng-swipe.module'; @NgModule({ declarations: [ @@ -45,7 +45,7 @@ import { DoubleNoCoverRendererComponent } from './_components/double-renderer-no SharedModule, ReaderSharedModule, - SwipeModule + NgSwipeModule ], exports: [ MangaReaderComponent diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts index a3db6dc67..d809f2566 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts @@ -329,6 +329,7 @@ export class MetadataFilterComponent implements OnInit, OnDestroy { this.ageRatingSettings.compareFn = (options: AgeRatingDto[], filter: string) => { return options.filter(m => this.utilityService.filter(m.title, filter)); } + this.ageRatingSettings.selectionCompareFn = (a: AgeRatingDto, b: AgeRatingDto) => { return a.title == b.title; diff --git a/UI/Web/src/app/ng-swipe/ag-swipe.core.ts b/UI/Web/src/app/ng-swipe/ag-swipe.core.ts new file mode 100644 index 000000000..eed00f17b --- /dev/null +++ b/UI/Web/src/app/ng-swipe/ag-swipe.core.ts @@ -0,0 +1,103 @@ +import { fromEvent, Observable, race, Subscription } from 'rxjs'; +import { elementAt, map, switchMap, takeUntil, tap } from 'rxjs/operators'; + +export interface SwipeCoordinates { + x: number; + y: number; + } + + export enum SwipeDirection { + X = 'x', + Y = 'y' + } + + export interface SwipeStartEvent { + x: number; + y: number; + direction: SwipeDirection; + } + + export interface SwipeEvent { + direction: SwipeDirection; + distance: number; + } + + export interface SwipeSubscriptionConfig { + domElement: HTMLElement; + onSwipeMove?: (event: SwipeEvent) => void; + onSwipeEnd?: (event: SwipeEvent) => void; + } + + +export function createSwipeSubscription({ domElement, onSwipeMove, onSwipeEnd }: SwipeSubscriptionConfig): Subscription { + if (!(domElement instanceof HTMLElement)) { + throw new Error('Provided domElement should be an instance of HTMLElement'); + } + + if ((typeof onSwipeMove !== 'function') && (typeof onSwipeEnd !== 'function')) { + throw new Error('At least one of the following swipe event handler functions should be provided: onSwipeMove and/or onSwipeEnd'); + } + + const touchStarts$ = fromEvent(domElement, 'touchstart').pipe(map(getTouchCoordinates)); + const touchMoves$ = fromEvent(domElement, 'touchmove').pipe(map(getTouchCoordinates)); + const touchEnds$ = fromEvent(domElement, 'touchend').pipe(map(getTouchCoordinates)); + const touchCancels$ = fromEvent(domElement, 'touchcancel'); + + const touchStartsWithDirection$: Observable = touchStarts$.pipe( + switchMap((touchStartEvent: SwipeCoordinates) => touchMoves$.pipe( + elementAt(3), + map((touchMoveEvent: SwipeCoordinates) => ({ + x: touchStartEvent.x, + y: touchStartEvent.y, + direction: getTouchDirection(touchStartEvent, touchMoveEvent) + }) + )) + ) + ); + + return touchStartsWithDirection$.pipe( + switchMap(touchStartEvent => touchMoves$.pipe( + map(touchMoveEvent => getTouchDistance(touchStartEvent, touchMoveEvent)), + tap((coordinates: SwipeCoordinates) => { + if (typeof onSwipeMove !== 'function') { return; } + onSwipeMove(getSwipeEvent(touchStartEvent, coordinates)); + }), + takeUntil(race( + touchEnds$.pipe( + map(touchEndEvent => getTouchDistance(touchStartEvent, touchEndEvent)), + tap((coordinates: SwipeCoordinates) => { + if (typeof onSwipeEnd !== 'function') { return; } + onSwipeEnd(getSwipeEvent(touchStartEvent, coordinates)); + }) + ), + touchCancels$ + )) + )) + ).subscribe(); +} + +function getTouchCoordinates(touchEvent: TouchEvent): SwipeCoordinates { + return { + x: touchEvent.changedTouches[0].clientX, + y: touchEvent.changedTouches[0].clientY + }; +} + +function getTouchDistance(startCoordinates: SwipeCoordinates, moveCoordinates: SwipeCoordinates): SwipeCoordinates { + return { + x: moveCoordinates.x - startCoordinates.x, + y: moveCoordinates.y - startCoordinates.y + }; +} + +function getTouchDirection(startCoordinates: SwipeCoordinates, moveCoordinates: SwipeCoordinates): SwipeDirection { + const { x, y } = getTouchDistance(startCoordinates, moveCoordinates); + return Math.abs(x) < Math.abs(y) ? SwipeDirection.Y : SwipeDirection.X; +} + +function getSwipeEvent(touchStartEvent: SwipeStartEvent, coordinates: SwipeCoordinates): SwipeEvent { + return { + direction: touchStartEvent.direction, + distance: coordinates[touchStartEvent.direction] + }; +} diff --git a/UI/Web/src/app/ng-swipe/ng-swipe.directive.ts b/UI/Web/src/app/ng-swipe/ng-swipe.directive.ts new file mode 100644 index 000000000..1e5bdc387 --- /dev/null +++ b/UI/Web/src/app/ng-swipe/ng-swipe.directive.ts @@ -0,0 +1,32 @@ +import { Directive, ElementRef, EventEmitter, NgZone, OnDestroy, OnInit, Output } from '@angular/core'; +import { Subscription } from 'rxjs'; +import { createSwipeSubscription, SwipeEvent } from './ag-swipe.core'; + +@Directive({ + selector: '[ngSwipe]' +}) +export class SwipeDirective implements OnInit, OnDestroy { + private swipeSubscription: Subscription | undefined; + + @Output() swipeMove: EventEmitter = new EventEmitter(); + @Output() swipeEnd: EventEmitter = new EventEmitter(); + + constructor( + private elementRef: ElementRef, + private zone: NgZone + ) {} + + ngOnInit() { + this.zone.runOutsideAngular(() => { + this.swipeSubscription = createSwipeSubscription({ + domElement: this.elementRef.nativeElement, + onSwipeMove: (swipeMoveEvent: SwipeEvent) => this.swipeMove.emit(swipeMoveEvent), + onSwipeEnd: (swipeEndEvent: SwipeEvent) => this.swipeEnd.emit(swipeEndEvent) + }); + }); + } + + ngOnDestroy() { + this.swipeSubscription?.unsubscribe?.(); + } +} diff --git a/UI/Web/src/app/ng-swipe/ng-swipe.module.ts b/UI/Web/src/app/ng-swipe/ng-swipe.module.ts new file mode 100644 index 000000000..31c10ea89 --- /dev/null +++ b/UI/Web/src/app/ng-swipe/ng-swipe.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SwipeDirective } from './ng-swipe.directive'; + +// All code in this module is based on https://github.com/aGoncharuks/ag-swipe and may contain further enhancements or bugfixes. + +@NgModule({ + declarations: [ + SwipeDirective + ], + imports: [ + CommonModule + ], + exports: [ + SwipeDirective + ] +}) +export class NgSwipeModule { } diff --git a/UI/Web/src/app/pipe/time-duration.pipe.ts b/UI/Web/src/app/pipe/time-duration.pipe.ts index ae1a6f1ba..728c1b1f0 100644 --- a/UI/Web/src/app/pipe/time-duration.pipe.ts +++ b/UI/Web/src/app/pipe/time-duration.pipe.ts @@ -13,7 +13,7 @@ export class TimeDurationPipe implements PipeTransform { if (hours < 1) { return `${(hours * 60).toFixed(1)} minutes`; } else if (hours < 24) { - return `${hours} hours`; + return `${hours.toFixed(1)} hours`; } else if (hours < 720) { return `${(hours / 24).toFixed(1)} days`; } else if (hours < 8760) { diff --git a/UI/Web/src/app/shared/_services/utility.service.ts b/UI/Web/src/app/shared/_services/utility.service.ts index a2660f141..845629612 100644 --- a/UI/Web/src/app/shared/_services/utility.service.ts +++ b/UI/Web/src/app/shared/_services/utility.service.ts @@ -92,11 +92,17 @@ export class UtilityService { } filter(input: string, filter: string): boolean { - if (input === null || filter === null) return false; + if (input === null || filter === null || input === undefined || filter === undefined) return false; const reg = /[_\.\-]/gi; return input.toUpperCase().replace(reg, '').includes(filter.toUpperCase().replace(reg, '')); } + filterMatches(input: string, filter: string): boolean { + if (input === null || filter === null || input === undefined || filter === undefined) return false; + const reg = /[_\.\-]/gi; + return input.toUpperCase().replace(reg, '') === filter.toUpperCase().replace(reg, ''); + } + isVolume(d: any) { return d != null && d.hasOwnProperty('chapters'); } diff --git a/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html b/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html index df32cfb00..740be1b45 100644 --- a/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html +++ b/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html @@ -29,7 +29,7 @@
- {{avgHoursPerWeekSpentReading | timeDuration}} + {{avgHoursPerWeekSpentReading | timeDuration}}
diff --git a/UI/Web/src/app/typeahead/_components/typeahead.component.ts b/UI/Web/src/app/typeahead/_components/typeahead.component.ts index 28dd38cae..5bc79395b 100644 --- a/UI/Web/src/app/typeahead/_components/typeahead.component.ts +++ b/UI/Web/src/app/typeahead/_components/typeahead.component.ts @@ -236,16 +236,16 @@ export class TypeaheadComponent implements OnInit, OnDestroy { this.filteredOptions = this.typeaheadForm.get('typeahead')!.valueChanges .pipe( // Adjust input box to grow - tap(val => { + tap((val: string) => { if (this.inputElem != null && this.inputElem.nativeElement != null) { this.renderer2.setStyle(this.inputElem.nativeElement, 'width', 15 * (val.trim().length + 1) + 'px'); this.focusedIndex = 0; } }), - map(val => val.trim()), + map((val: string) => val.trim()), auditTime(this.settings.debounce), //distinctUntilChanged(), // ?!: BUG Doesn't trigger the search to run when filtered array changes - filter(val => { + filter((val: string) => { // If minimum filter characters not met, do not filter if (this.settings.minCharacters === 0) return true; @@ -256,11 +256,11 @@ export class TypeaheadComponent implements OnInit, OnDestroy { return true; }), - switchMap(val => { + switchMap((val: string) => { this.isLoadingOptions = true; return this.settings.fetchFn(val.trim()).pipe(takeUntil(this.onDestroy), map((items: any[]) => items.filter(item => this.filterSelected(item)))); }), - tap((filteredOptions) => { + tap((filteredOptions: any[]) => { this.isLoadingOptions = false; this.focusedIndex = 0; this.cdRef.markForCheck(); @@ -398,6 +398,7 @@ export class TypeaheadComponent implements OnInit, OnDestroy { } this.toggleSelection(opt); + console.log('Selected ', opt); this.resetField(); this.onInputFocus(); @@ -410,6 +411,7 @@ export class TypeaheadComponent implements OnInit, OnDestroy { const newItem = this.settings.addTransformFn(title); this.newItemAdded.emit(newItem); this.toggleSelection(newItem); + console.log('Selected ', newItem); this.resetField(); this.onInputFocus(); @@ -482,14 +484,43 @@ export class TypeaheadComponent implements OnInit, OnDestroy { updateShowAddItem(options: any[]) { // ?! BUG This will still technicially allow you to add the same thing as a previously added item. (Code will just toggle it though) - this.showAddItem = this.settings.addIfNonExisting && this.typeaheadControl.value.trim() - && this.typeaheadControl.value.trim().length >= Math.max(this.settings.minCharacters, 1) - && this.typeaheadControl.dirty - && (typeof this.settings.compareFn == 'function' && this.settings.compareFn(options, this.typeaheadControl.value.trim()).length === 0); + this.showAddItem = false; + this.cdRef.markForCheck(); + if (!this.settings.addIfNonExisting) return; + + const inputText = this.typeaheadControl.value.trim(); + if (inputText.length < Math.max(this.settings.minCharacters, 1)) return; + if (!this.typeaheadControl.dirty) return; // Do we need this? + + // Check if this new option will interfere with any existing ones not shown + + if (typeof this.settings.compareFnForAdd == 'function') { + console.log('filtered options: ', this.optionSelection.selected()); + const willDuplicateExist = this.settings.compareFnForAdd(this.optionSelection.selected(), inputText); + console.log('duplicate check: ', willDuplicateExist); + if (willDuplicateExist.length > 0) { + console.log("can't show add, duplicates will exist"); + return; + } + } + + if (typeof this.settings.compareFn == 'function') { + // The problem here is that compareFn can report that duplicate will exist as it does contains not match + const matches = this.settings.compareFn(options, inputText); + console.log('matches for ', inputText, ': ', matches); + console.log('matches include input string: ', matches.includes(this.settings.addTransformFn(inputText))); + if (matches.length > 0 && matches.includes(this.settings.addTransformFn(inputText))) { + console.log("can't show add, there are still "); + return; + } + } + + this.showAddItem = true; if (this.showAddItem) { this.hasFocus = true; } + this.cdRef.markForCheck(); } toggleLock(event: any) { diff --git a/UI/Web/src/app/typeahead/_models/typeahead-settings.ts b/UI/Web/src/app/typeahead/_models/typeahead-settings.ts index c0cbe648f..12424a1bc 100644 --- a/UI/Web/src/app/typeahead/_models/typeahead-settings.ts +++ b/UI/Web/src/app/typeahead/_models/typeahead-settings.ts @@ -29,9 +29,13 @@ export class TypeaheadSettings { savedData!: T[] | T; /** * Function to compare the elements. Should return all elements that fit the matching criteria. - * This is only used with non-Observable based fetchFn, but must be defined for all uses of typeahead (TODO) + * This is only used with non-Observable based fetchFn, but must be defined for all uses of typeahead. */ compareFn!: ((optionList: T[], filter: string) => T[]); + /** + * Must be defined when addIfNonExisting is true. Used to ensure no duplicates exist when adding. + */ + compareFnForAdd!: ((optionList: T[], filter: string) => T[]); /** * Function which is used for comparing objects when keeping track of state. * Useful over shallow equal when you have image urls that have random numbers on them. diff --git a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html index 6760c4218..bdc093d8d 100644 --- a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html +++ b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html @@ -9,7 +9,7 @@

- Looking for a light or e-ink theme? We have some custom themes you can use on our wiki. + Looking for a light or e-ink theme? We have some custom themes you can use on our theme github.

diff --git a/UI/Web/src/index.html b/UI/Web/src/index.html index 4ec267af3..4ce7fd914 100644 --- a/UI/Web/src/index.html +++ b/UI/Web/src/index.html @@ -14,8 +14,8 @@ - - + + diff --git a/UI/Web/src/theme/components/_input.scss b/UI/Web/src/theme/components/_input.scss index 231be419a..0553b9750 100644 --- a/UI/Web/src/theme/components/_input.scss +++ b/UI/Web/src/theme/components/_input.scss @@ -24,10 +24,10 @@ input:not([type="range"]), .form-control { } } -.form-range::-webkit-slider-thumb:active { +.form-range::-webkit-slider-thumb:active, .form-range::-moz-range-thumb:active { background-color: var(--input-range-active-color); } -.form-range::-webkit-slider-thumb { +.form-range::-webkit-slider-thumb, .form-range::-moz-range-thumb { background-color: var(--input-range-color); } diff --git a/openapi.json b/openapi.json index 1b8e479d6..29a292bb0 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.7.1.20" + "version": "0.7.1.22" }, "servers": [ { @@ -3631,6 +3631,36 @@ } } }, + "/api/Reader/thumbnail": { + "get": { + "tags": [ + "Reader" + ], + "parameters": [ + { + "name": "chapterId", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "pageNum", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/api/Reader/bookmark-image": { "get": { "tags": [ @@ -3739,6 +3769,7 @@ "Reader" ], "summary": "Returns various information about a Chapter. Side effect: This will cache the chapter images for reading.", + "description": "This is generally the first call when attempting to read to allow pre-generation of assets needed for reading", "parameters": [ { "name": "chapterId",