diff --git a/API.Benchmark/API.Benchmark.csproj b/API.Benchmark/API.Benchmark.csproj
index 38ec425fe..d6fd4eb9f 100644
--- a/API.Benchmark/API.Benchmark.csproj
+++ b/API.Benchmark/API.Benchmark.csproj
@@ -26,5 +26,10 @@
Always
+
+
+ PreserveNewest
+
+
diff --git a/API.Benchmark/Data/AesopsFables.epub b/API.Benchmark/Data/AesopsFables.epub
new file mode 100644
index 000000000..d2ab9a8b2
Binary files /dev/null and b/API.Benchmark/Data/AesopsFables.epub differ
diff --git a/API.Benchmark/KoreaderHashBenchmark.cs b/API.Benchmark/KoreaderHashBenchmark.cs
new file mode 100644
index 000000000..c0abfd2ad
--- /dev/null
+++ b/API.Benchmark/KoreaderHashBenchmark.cs
@@ -0,0 +1,41 @@
+using API.Helpers.Builders;
+using BenchmarkDotNet.Attributes;
+using BenchmarkDotNet.Order;
+using System;
+using API.Entities.Enums;
+
+namespace API.Benchmark
+{
+ [StopOnFirstError]
+ [MemoryDiagnoser]
+ [RankColumn]
+ [Orderer(SummaryOrderPolicy.FastestToSlowest)]
+ [SimpleJob(launchCount: 1, warmupCount: 5, invocationCount: 20)]
+ public class KoreaderHashBenchmark
+ {
+ private const string sourceEpub = "./Data/AesopsFables.epub";
+
+ [Benchmark(Baseline = true)]
+ public void TestBuildManga_baseline()
+ {
+ var file = new MangaFileBuilder(sourceEpub, MangaFormat.Epub)
+ .Build();
+ if (file == null)
+ {
+ throw new Exception("Failed to build manga file");
+ }
+ }
+
+ [Benchmark]
+ public void TestBuildManga_withHash()
+ {
+ var file = new MangaFileBuilder(sourceEpub, MangaFormat.Epub)
+ .WithHash()
+ .Build();
+ if (file == null)
+ {
+ throw new Exception("Failed to build manga file");
+ }
+ }
+ }
+}
diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj
index 9e7fc3a02..73b886e13 100644
--- a/API.Tests/API.Tests.csproj
+++ b/API.Tests/API.Tests.csproj
@@ -36,4 +36,10 @@
+
+
+ PreserveNewest
+
+
+
diff --git a/API.Tests/Data/AesopsFables.epub b/API.Tests/Data/AesopsFables.epub
new file mode 100644
index 000000000..d2ab9a8b2
Binary files /dev/null and b/API.Tests/Data/AesopsFables.epub differ
diff --git a/API.Tests/Extensions/QueryableExtensionsTests.cs b/API.Tests/Extensions/QueryableExtensionsTests.cs
index 866e0202c..96d74b46d 100644
--- a/API.Tests/Extensions/QueryableExtensionsTests.cs
+++ b/API.Tests/Extensions/QueryableExtensionsTests.cs
@@ -67,7 +67,7 @@ public class QueryableExtensionsTests
[Theory]
[InlineData(true, 2)]
- [InlineData(false, 1)]
+ [InlineData(false, 2)]
public void RestrictAgainstAgeRestriction_Genre_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount)
{
var items = new List()
@@ -94,7 +94,7 @@ public class QueryableExtensionsTests
[Theory]
[InlineData(true, 2)]
- [InlineData(false, 1)]
+ [InlineData(false, 2)]
public void RestrictAgainstAgeRestriction_Tag_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount)
{
var items = new List()
diff --git a/API.Tests/Helpers/KoreaderHelperTests.cs b/API.Tests/Helpers/KoreaderHelperTests.cs
new file mode 100644
index 000000000..66d287a5d
--- /dev/null
+++ b/API.Tests/Helpers/KoreaderHelperTests.cs
@@ -0,0 +1,60 @@
+using API.DTOs.Koreader;
+using API.DTOs.Progress;
+using API.Helpers;
+using System.Runtime.CompilerServices;
+using Xunit;
+
+namespace API.Tests.Helpers;
+
+
+public class KoreaderHelperTests
+{
+
+ [Theory]
+ [InlineData("/body/DocFragment[11]/body/div/a", 10, null)]
+ [InlineData("/body/DocFragment[1]/body/div/p[40]", 0, 40)]
+ [InlineData("/body/DocFragment[8]/body/div/p[28]/text().264", 7, 28)]
+ public void GetEpubPositionDto(string koreaderPosition, int page, int? pNumber)
+ {
+ var expected = EmptyProgressDto();
+ expected.BookScrollId = pNumber.HasValue ? $"//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/P[{pNumber}]" : null;
+ expected.PageNum = page;
+ var actual = EmptyProgressDto();
+
+ KoreaderHelper.UpdateProgressDto(actual, koreaderPosition);
+ Assert.Equal(expected.BookScrollId, actual.BookScrollId);
+ Assert.Equal(expected.PageNum, actual.PageNum);
+ }
+
+
+ [Theory]
+ [InlineData("//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/P[20]", 5, "/body/DocFragment[6]/body/div/p[20]")]
+ [InlineData(null, 10, "/body/DocFragment[11]/body/div/a")]
+ public void GetKoreaderPosition(string scrollId, int page, string koreaderPosition)
+ {
+ var given = EmptyProgressDto();
+ given.BookScrollId = scrollId;
+ given.PageNum = page;
+
+ Assert.Equal(koreaderPosition, KoreaderHelper.GetKoreaderPosition(given));
+ }
+
+ [Theory]
+ [InlineData("./Data/AesopsFables.epub", "8795ACA4BF264B57C1EEDF06A0CEE688")]
+ public void GetKoreaderHash(string filePath, string hash)
+ {
+ Assert.Equal(KoreaderHelper.HashContents(filePath), hash);
+ }
+
+ private ProgressDto EmptyProgressDto()
+ {
+ return new ProgressDto
+ {
+ ChapterId = 0,
+ PageNum = 0,
+ VolumeId = 0,
+ SeriesId = 0,
+ LibraryId = 0
+ };
+ }
+}
diff --git a/API.Tests/Services/ExternalMetadataServiceTests.cs b/API.Tests/Services/ExternalMetadataServiceTests.cs
index 8310ed269..833e8fe5f 100644
--- a/API.Tests/Services/ExternalMetadataServiceTests.cs
+++ b/API.Tests/Services/ExternalMetadataServiceTests.cs
@@ -2935,6 +2935,8 @@ public class ExternalMetadataServiceTests : AbstractDbTest
metadataSettings.EnableTags = false;
metadataSettings.EnablePublicationStatus = false;
metadataSettings.EnableStartDate = false;
+ metadataSettings.FieldMappings = [];
+ metadataSettings.AgeRatingMappings = new Dictionary();
Context.MetadataSettings.Update(metadataSettings);
await Context.SaveChangesAsync();
diff --git a/API.Tests/Services/ScrobblingServiceTests.cs b/API.Tests/Services/ScrobblingServiceTests.cs
index 50398a146..9245c8ecd 100644
--- a/API.Tests/Services/ScrobblingServiceTests.cs
+++ b/API.Tests/Services/ScrobblingServiceTests.cs
@@ -1,11 +1,17 @@
-using System.Linq;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
+using API.Data.Repositories;
using API.DTOs.Scrobbling;
+using API.Entities;
using API.Entities.Enums;
+using API.Entities.Scrobble;
using API.Helpers.Builders;
using API.Services;
using API.Services.Plus;
using API.SignalR;
+using Kavita.Common;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
@@ -15,11 +21,33 @@ namespace API.Tests.Services;
public class ScrobblingServiceTests : AbstractDbTest
{
+ private const int ChapterPages = 100;
+
+ ///
+ /// {
+ /// "Issuer": "Issuer",
+ /// "Issued At": "2025-06-15T21:01:57.615Z",
+ /// "Expiration": "2200-06-15T21:01:57.615Z"
+ /// }
+ ///
+ /// Our UnitTests will fail in 2200 :(
+ private const string ValidJwtToken =
+ "eyJhbGciOiJIUzI1NiJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJleHAiOjcyNzI0NTAxMTcsImlhdCI6MTc1MDAyMTMxN30.zADmcGq_BfxbcV8vy4xw5Cbzn4COkmVINxgqpuL17Ng";
+
private readonly ScrobblingService _service;
private readonly ILicenseService _licenseService;
private readonly ILocalizationService _localizationService;
private readonly ILogger _logger;
private readonly IEmailService _emailService;
+ private readonly IKavitaPlusApiService _kavitaPlusApiService;
+ ///
+ /// IReaderService, without the ScrobblingService injected
+ ///
+ private readonly IReaderService _readerService;
+ ///
+ /// IReaderService, with the _service injected
+ ///
+ private readonly IReaderService _hookedUpReaderService;
public ScrobblingServiceTests()
{
@@ -27,8 +55,24 @@ public class ScrobblingServiceTests : AbstractDbTest
_localizationService = Substitute.For();
_logger = Substitute.For>();
_emailService = Substitute.For();
+ _kavitaPlusApiService = Substitute.For();
- _service = new ScrobblingService(UnitOfWork, Substitute.For(), _logger, _licenseService, _localizationService, _emailService);
+ _service = new ScrobblingService(UnitOfWork, Substitute.For(), _logger, _licenseService,
+ _localizationService, _emailService, _kavitaPlusApiService);
+
+ _readerService = new ReaderService(UnitOfWork,
+ Substitute.For>(),
+ Substitute.For(),
+ Substitute.For(),
+ Substitute.For(),
+ Substitute.For()); // Do not use the actual one
+
+ _hookedUpReaderService = new ReaderService(UnitOfWork,
+ Substitute.For>(),
+ Substitute.For(),
+ Substitute.For(),
+ Substitute.For(),
+ _service);
}
protected override async Task ResetDb()
@@ -46,6 +90,30 @@ public class ScrobblingServiceTests : AbstractDbTest
var series = new SeriesBuilder("Test Series")
.WithFormat(MangaFormat.Archive)
.WithMetadata(new SeriesMetadataBuilder().Build())
+ .WithVolume(new VolumeBuilder("Volume 1")
+ .WithChapters([
+ new ChapterBuilder("1")
+ .WithPages(ChapterPages)
+ .Build(),
+ new ChapterBuilder("2")
+ .WithPages(ChapterPages)
+ .Build(),
+ new ChapterBuilder("3")
+ .WithPages(ChapterPages)
+ .Build()])
+ .Build())
+ .WithVolume(new VolumeBuilder("Volume 2")
+ .WithChapters([
+ new ChapterBuilder("4")
+ .WithPages(ChapterPages)
+ .Build(),
+ new ChapterBuilder("5")
+ .WithPages(ChapterPages)
+ .Build(),
+ new ChapterBuilder("6")
+ .WithPages(ChapterPages)
+ .Build()])
+ .Build())
.Build();
var library = new LibraryBuilder("Test Library", LibraryType.Manga)
@@ -67,6 +135,296 @@ public class ScrobblingServiceTests : AbstractDbTest
await UnitOfWork.CommitAsync();
}
+ private async Task CreateScrobbleEvent(int? seriesId = null)
+ {
+ var evt = new ScrobbleEvent
+ {
+ ScrobbleEventType = ScrobbleEventType.ChapterRead,
+ Format = PlusMediaFormat.Manga,
+ SeriesId = seriesId ?? 0,
+ LibraryId = 0,
+ AppUserId = 0,
+ };
+
+ if (seriesId != null)
+ {
+ var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId.Value);
+ if (series != null) evt.Series = series;
+ }
+
+ return evt;
+ }
+
+
+ #region K+ API Request Tests
+
+ [Fact]
+ public async Task PostScrobbleUpdate_AuthErrors()
+ {
+ _kavitaPlusApiService.PostScrobbleUpdate(null!, "")
+ .ReturnsForAnyArgs(new ScrobbleResponseDto()
+ {
+ ErrorMessage = "Unauthorized"
+ });
+
+ var evt = await CreateScrobbleEvent();
+ await Assert.ThrowsAsync(async () =>
+ {
+ await _service.PostScrobbleUpdate(new ScrobbleDto(), "", evt);
+ });
+ Assert.True(evt.IsErrored);
+ Assert.Equal("Kavita+ subscription no longer active", evt.ErrorDetails);
+ }
+
+ [Fact]
+ public async Task PostScrobbleUpdate_UnknownSeriesLoggedAsError()
+ {
+ _kavitaPlusApiService.PostScrobbleUpdate(null!, "")
+ .ReturnsForAnyArgs(new ScrobbleResponseDto()
+ {
+ ErrorMessage = "Unknown Series"
+ });
+
+ await SeedData();
+ var evt = await CreateScrobbleEvent(1);
+
+ await _service.PostScrobbleUpdate(new ScrobbleDto(), "", evt);
+ await UnitOfWork.CommitAsync();
+ Assert.True(evt.IsErrored);
+
+ var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1);
+ Assert.NotNull(series);
+ Assert.True(series.IsBlacklisted);
+
+ var errors = await UnitOfWork.ScrobbleRepository.GetAllScrobbleErrorsForSeries(1);
+ Assert.Single(errors);
+ Assert.Equal("Series cannot be matched for Scrobbling", errors.First().Comment);
+ Assert.Equal(series.Id, errors.First().SeriesId);
+ }
+
+ [Fact]
+ public async Task PostScrobbleUpdate_InvalidAccessToken()
+ {
+ _kavitaPlusApiService.PostScrobbleUpdate(null!, "")
+ .ReturnsForAnyArgs(new ScrobbleResponseDto()
+ {
+ ErrorMessage = "Access token is invalid"
+ });
+
+ var evt = await CreateScrobbleEvent();
+
+ await Assert.ThrowsAsync(async () =>
+ {
+ await _service.PostScrobbleUpdate(new ScrobbleDto(), "", evt);
+ });
+
+ Assert.True(evt.IsErrored);
+ Assert.Equal("Access Token needs to be rotated to continue scrobbling", evt.ErrorDetails);
+ }
+
+ #endregion
+
+ #region K+ API Request data tests
+
+ [Fact]
+ public async Task ProcessReadEvents_CreatesNoEventsWhenNoProgress()
+ {
+ await ResetDb();
+ await SeedData();
+
+ // Set Returns
+ _licenseService.HasActiveLicense().Returns(Task.FromResult(true));
+ _kavitaPlusApiService.GetRateLimit(Arg.Any(), Arg.Any())
+ .Returns(100);
+
+ var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1);
+ Assert.NotNull(user);
+
+ // Ensure CanProcessScrobbleEvent returns true
+ user.AniListAccessToken = ValidJwtToken;
+ UnitOfWork.UserRepository.Update(user);
+ await UnitOfWork.CommitAsync();
+
+ var chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(4);
+ Assert.NotNull(chapter);
+
+ var volume = await UnitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters);
+ Assert.NotNull(volume);
+
+ // Call Scrobble without having any progress
+ await _service.ScrobbleReadingUpdate(1, 1);
+ var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
+ Assert.Empty(events);
+ }
+
+ [Fact]
+ public async Task ProcessReadEvents_UpdateVolumeAndChapterData()
+ {
+ await ResetDb();
+ await SeedData();
+
+ // Set Returns
+ _licenseService.HasActiveLicense().Returns(Task.FromResult(true));
+ _kavitaPlusApiService.GetRateLimit(Arg.Any(), Arg.Any())
+ .Returns(100);
+
+ var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1);
+ Assert.NotNull(user);
+
+ // Ensure CanProcessScrobbleEvent returns true
+ user.AniListAccessToken = ValidJwtToken;
+ UnitOfWork.UserRepository.Update(user);
+ await UnitOfWork.CommitAsync();
+
+ var chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(4);
+ Assert.NotNull(chapter);
+
+ var volume = await UnitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters);
+ Assert.NotNull(volume);
+
+ // Mark something as read to trigger event creation
+ await _readerService.MarkChaptersAsRead(user, 1, new List() {volume.Chapters[0]});
+ await UnitOfWork.CommitAsync();
+
+ // Call Scrobble while having some progress
+ await _service.ScrobbleReadingUpdate(user.Id, 1);
+ var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
+ Assert.Single(events);
+
+ // Give it some (more) read progress
+ await _readerService.MarkChaptersAsRead(user, 1, volume.Chapters);
+ await _readerService.MarkChaptersAsRead(user, 1, [chapter]);
+ await UnitOfWork.CommitAsync();
+
+ await _service.ProcessUpdatesSinceLastSync();
+
+ await _kavitaPlusApiService.Received(1).PostScrobbleUpdate(
+ Arg.Is(data =>
+ data.ChapterNumber == (int)chapter.MaxNumber &&
+ data.VolumeNumber == (int)volume.MaxNumber
+ ),
+ Arg.Any());
+ }
+
+ #endregion
+
+ #region Scrobble Reading Update Tests
+
+ [Fact]
+ public async Task ScrobbleReadingUpdate_IgnoreNoLicense()
+ {
+ await ResetDb();
+ await SeedData();
+
+ _licenseService.HasActiveLicense().Returns(false);
+
+ await _service.ScrobbleReadingUpdate(1, 1);
+ var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
+ Assert.Empty(events);
+ }
+
+ [Fact]
+ public async Task ScrobbleReadingUpdate_RemoveWhenNoProgress()
+ {
+ await ResetDb();
+ await SeedData();
+
+ _licenseService.HasActiveLicense().Returns(true);
+
+ var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1);
+ Assert.NotNull(user);
+
+ var volume = await UnitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters);
+ Assert.NotNull(volume);
+
+ await _readerService.MarkChaptersAsRead(user, 1, new List() {volume.Chapters[0]});
+ await UnitOfWork.CommitAsync();
+
+ await _service.ScrobbleReadingUpdate(1, 1);
+ var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
+ Assert.Single(events);
+
+ var readEvent = events.First();
+ Assert.False(readEvent.IsProcessed);
+
+ await _hookedUpReaderService.MarkSeriesAsUnread(user, 1);
+ await UnitOfWork.CommitAsync();
+
+ // Existing event is deleted
+ await _service.ScrobbleReadingUpdate(1, 1);
+ events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
+ Assert.Empty(events);
+
+ await _hookedUpReaderService.MarkSeriesAsUnread(user, 1);
+ await UnitOfWork.CommitAsync();
+
+ // No new events are added
+ events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
+ Assert.Empty(events);
+ }
+
+ [Fact]
+ public async Task ScrobbleReadingUpdate_UpdateExistingNotIsProcessed()
+ {
+ await ResetDb();
+ await SeedData();
+
+ var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1);
+ Assert.NotNull(user);
+
+ var chapter1 = await UnitOfWork.ChapterRepository.GetChapterAsync(1);
+ var chapter2 = await UnitOfWork.ChapterRepository.GetChapterAsync(2);
+ var chapter3 = await UnitOfWork.ChapterRepository.GetChapterAsync(3);
+ Assert.NotNull(chapter1);
+ Assert.NotNull(chapter2);
+ Assert.NotNull(chapter3);
+
+ _licenseService.HasActiveLicense().Returns(true);
+
+ var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
+ Assert.Empty(events);
+
+
+ await _readerService.MarkChaptersAsRead(user, 1, [chapter1]);
+ await UnitOfWork.CommitAsync();
+
+ // Scrobble update
+ await _service.ScrobbleReadingUpdate(1, 1);
+ events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
+ Assert.Single(events);
+
+ var readEvent = events[0];
+ Assert.False(readEvent.IsProcessed);
+ Assert.Equal(1, readEvent.ChapterNumber);
+
+ // Mark as processed
+ readEvent.IsProcessed = true;
+ await UnitOfWork.CommitAsync();
+
+ await _readerService.MarkChaptersAsRead(user, 1, [chapter2]);
+ await UnitOfWork.CommitAsync();
+
+ // Scrobble update
+ await _service.ScrobbleReadingUpdate(1, 1);
+ events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
+ Assert.Equal(2, events.Count);
+ Assert.Single(events.Where(e => e.IsProcessed).ToList());
+ Assert.Single(events.Where(e => !e.IsProcessed).ToList());
+
+ // Should update the existing non processed event
+ await _readerService.MarkChaptersAsRead(user, 1, [chapter3]);
+ await UnitOfWork.CommitAsync();
+
+ // Scrobble update
+ await _service.ScrobbleReadingUpdate(1, 1);
+ events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
+ Assert.Equal(2, events.Count);
+ Assert.Single(events.Where(e => e.IsProcessed).ToList());
+ Assert.Single(events.Where(e => !e.IsProcessed).ToList());
+ }
+
+ #endregion
+
#region ScrobbleWantToReadUpdate Tests
[Fact]
@@ -203,6 +561,59 @@ public class ScrobblingServiceTests : AbstractDbTest
#endregion
+ #region Scrobble Rating Update Test
+
+ [Fact]
+ public async Task ScrobbleRatingUpdate_IgnoreNoLicense()
+ {
+ await ResetDb();
+ await SeedData();
+
+ _licenseService.HasActiveLicense().Returns(false);
+
+ await _service.ScrobbleRatingUpdate(1, 1, 1);
+ var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
+ Assert.Empty(events);
+ }
+
+ [Fact]
+ public async Task ScrobbleRatingUpdate_UpdateExistingNotIsProcessed()
+ {
+ await ResetDb();
+ await SeedData();
+
+ _licenseService.HasActiveLicense().Returns(true);
+
+ var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1);
+ Assert.NotNull(user);
+
+ var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1);
+ Assert.NotNull(series);
+
+ await _service.ScrobbleRatingUpdate(user.Id, series.Id, 1);
+ var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
+ Assert.Single(events);
+ Assert.Equal(1, events.First().Rating);
+
+ // Mark as processed
+ events.First().IsProcessed = true;
+ await UnitOfWork.CommitAsync();
+
+ await _service.ScrobbleRatingUpdate(user.Id, series.Id, 5);
+ events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
+ Assert.Equal(2, events.Count);
+ Assert.Single(events, evt => evt.IsProcessed);
+ Assert.Single(events, evt => !evt.IsProcessed);
+
+ await _service.ScrobbleRatingUpdate(user.Id, series.Id, 5);
+ events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
+ Assert.Single(events, evt => !evt.IsProcessed);
+ Assert.Equal(5, events.First(evt => !evt.IsProcessed).Rating);
+
+ }
+
+ #endregion
+
[Theory]
[InlineData("https://anilist.co/manga/35851/Byeontaega-Doeja/", 35851)]
[InlineData("https://anilist.co/manga/30105", 30105)]
diff --git a/API/Controllers/KoreaderController.cs b/API/Controllers/KoreaderController.cs
new file mode 100644
index 000000000..1ce5e3202
--- /dev/null
+++ b/API/Controllers/KoreaderController.cs
@@ -0,0 +1,118 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using System;
+using System.Threading.Tasks;
+using API.Data;
+using API.Data.Repositories;
+using API.DTOs.Koreader;
+using API.Entities;
+using API.Services;
+using Kavita.Common;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using static System.Net.WebRequestMethods;
+
+namespace API.Controllers;
+#nullable enable
+
+///
+/// The endpoint to interface with Koreader's Progress Sync plugin.
+///
+///
+/// Koreader uses a different form of authentication. It stores the username and password in headers.
+/// https://github.com/koreader/koreader/blob/master/plugins/kosync.koplugin/KOSyncClient.lua
+///
+[AllowAnonymous]
+public class KoreaderController : BaseApiController
+{
+
+ private readonly IUnitOfWork _unitOfWork;
+ private readonly ILocalizationService _localizationService;
+ private readonly IKoreaderService _koreaderService;
+ private readonly ILogger _logger;
+
+ public KoreaderController(IUnitOfWork unitOfWork, ILocalizationService localizationService,
+ IKoreaderService koreaderService, ILogger logger)
+ {
+ _unitOfWork = unitOfWork;
+ _localizationService = localizationService;
+ _koreaderService = koreaderService;
+ _logger = logger;
+ }
+
+ // We won't allow users to be created from Koreader. Rather, they
+ // must already have an account.
+ /*
+ [HttpPost("/users/create")]
+ public IActionResult CreateUser(CreateUserRequest request)
+ {
+ }
+ */
+
+ [HttpGet("{apiKey}/users/auth")]
+ public async Task Authenticate(string apiKey)
+ {
+ var userId = await GetUserId(apiKey);
+ var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
+ if (user == null) return Unauthorized();
+
+ return Ok(new { username = user.UserName });
+ }
+
+ ///
+ /// Syncs book progress with Kavita. Will attempt to save the underlying reader position if possible.
+ ///
+ ///
+ ///
+ ///
+ [HttpPut("{apiKey}/syncs/progress")]
+ public async Task> UpdateProgress(string apiKey, KoreaderBookDto request)
+ {
+ try
+ {
+ var userId = await GetUserId(apiKey);
+ await _koreaderService.SaveProgress(request, userId);
+
+ return Ok(new KoreaderProgressUpdateDto{ Document = request.Document, Timestamp = DateTime.UtcNow });
+ }
+ catch (KavitaException ex)
+ {
+ return BadRequest(ex.Message);
+ }
+ }
+
+ ///
+ /// Gets book progress from Kavita, if not found will return a 400
+ ///
+ ///
+ ///
+ ///
+ [HttpGet("{apiKey}/syncs/progress/{ebookHash}")]
+ public async Task> GetProgress(string apiKey, string ebookHash)
+ {
+ try
+ {
+ var userId = await GetUserId(apiKey);
+ var response = await _koreaderService.GetProgress(ebookHash, userId);
+ _logger.LogDebug("Koreader response progress: {Progress}", response.Progress);
+
+ return Ok(response);
+ }
+ catch (KavitaException ex)
+ {
+ return BadRequest(ex.Message);
+ }
+ }
+
+ private async Task GetUserId(string apiKey)
+ {
+ try
+ {
+ return await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
+ }
+ catch
+ {
+ throw new KavitaException(await _localizationService.Get("en", "user-doesnt-exist"));
+ }
+ }
+}
diff --git a/API/Controllers/ScrobblingController.cs b/API/Controllers/ScrobblingController.cs
index 3904cb8e0..986f4f8e7 100644
--- a/API/Controllers/ScrobblingController.cs
+++ b/API/Controllers/ScrobblingController.cs
@@ -254,7 +254,7 @@ public class ScrobblingController : BaseApiController
}
///
- /// Adds a hold against the Series for user's scrobbling
+ /// Remove a hold against the Series for user's scrobbling
///
///
///
@@ -281,4 +281,18 @@ public class ScrobblingController : BaseApiController
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId());
return Ok(user is {HasRunScrobbleEventGeneration: true});
}
+
+ ///
+ /// Delete the given scrobble events if they belong to that user
+ ///
+ ///
+ ///
+ [HttpPost("bulk-remove-events")]
+ public async Task BulkRemoveScrobbleEvents(IList eventIds)
+ {
+ var events = await _unitOfWork.ScrobbleRepository.GetUserEvents(User.GetUserId(), eventIds);
+ _unitOfWork.ScrobbleRepository.Remove(events);
+ await _unitOfWork.CommitAsync();
+ return Ok();
+ }
}
diff --git a/API/DTOs/Koreader/KoreaderBookDto.cs b/API/DTOs/Koreader/KoreaderBookDto.cs
new file mode 100644
index 000000000..b66b7da3a
--- /dev/null
+++ b/API/DTOs/Koreader/KoreaderBookDto.cs
@@ -0,0 +1,33 @@
+using API.DTOs.Progress;
+
+namespace API.DTOs.Koreader;
+
+///
+/// This is the interface for receiving and sending updates to Koreader. The only fields
+/// that are actually used are the Document and Progress fields.
+///
+public class KoreaderBookDto
+{
+ ///
+ /// This is the Koreader hash of the book. It is used to identify the book.
+ ///
+ public string Document { get; set; }
+ ///
+ /// A randomly generated id from the koreader device. Only used to maintain the Koreader interface.
+ ///
+ public string Device_id { get; set; }
+ ///
+ /// The Koreader device name. Only used to maintain the Koreader interface.
+ ///
+ public string Device { get; set; }
+ ///
+ /// Percent progress of the book. Only used to maintain the Koreader interface.
+ ///
+ public float Percentage { get; set; }
+ ///
+ /// An XPath string read by Koreader to determine the location within the epub.
+ /// Essentially, it is Koreader's equivalent to ProgressDto.BookScrollId.
+ ///
+ ///
+ public string Progress { get; set; }
+}
diff --git a/API/DTOs/Koreader/KoreaderProgressUpdateDto.cs b/API/DTOs/Koreader/KoreaderProgressUpdateDto.cs
new file mode 100644
index 000000000..52a1d6cbd
--- /dev/null
+++ b/API/DTOs/Koreader/KoreaderProgressUpdateDto.cs
@@ -0,0 +1,15 @@
+using System;
+
+namespace API.DTOs.Koreader;
+
+public class KoreaderProgressUpdateDto
+{
+ ///
+ /// This is the Koreader hash of the book. It is used to identify the book.
+ ///
+ public string Document { get; set; }
+ ///
+ /// UTC Timestamp to return to KOReader
+ ///
+ public DateTime Timestamp { get; set; }
+}
diff --git a/API/DTOs/Scrobbling/ScrobbleEventDto.cs b/API/DTOs/Scrobbling/ScrobbleEventDto.cs
index 7b1ccd75a..562d923ff 100644
--- a/API/DTOs/Scrobbling/ScrobbleEventDto.cs
+++ b/API/DTOs/Scrobbling/ScrobbleEventDto.cs
@@ -5,6 +5,7 @@ namespace API.DTOs.Scrobbling;
public sealed record ScrobbleEventDto
{
+ public long Id { get; init; }
public string SeriesName { get; set; }
public int SeriesId { get; set; }
public int LibraryId { get; set; }
diff --git a/API/DTOs/Scrobbling/ScrobbleResponseDto.cs b/API/DTOs/Scrobbling/ScrobbleResponseDto.cs
index 53d3a0cc9..ad66729d0 100644
--- a/API/DTOs/Scrobbling/ScrobbleResponseDto.cs
+++ b/API/DTOs/Scrobbling/ScrobbleResponseDto.cs
@@ -8,5 +8,6 @@ public sealed record ScrobbleResponseDto
{
public bool Successful { get; set; }
public string? ErrorMessage { get; set; }
+ public string? ExtraInformation {get; set;}
public int RateLeft { get; set; }
}
diff --git a/API/Data/Migrations/20250519151126_KoreaderHash.Designer.cs b/API/Data/Migrations/20250519151126_KoreaderHash.Designer.cs
new file mode 100644
index 000000000..79f6f9504
--- /dev/null
+++ b/API/Data/Migrations/20250519151126_KoreaderHash.Designer.cs
@@ -0,0 +1,3574 @@
+//
+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("20250519151126_KoreaderHash")]
+ partial class KoreaderHash
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "9.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("AniListAccessToken")
+ .HasColumnType("TEXT");
+
+ b.Property("ApiKey")
+ .HasColumnType("TEXT");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("ConfirmationToken")
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("HasRunScrobbleEventGeneration")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastActive")
+ .HasColumnType("TEXT");
+
+ b.Property("LastActiveUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("TEXT");
+
+ b.Property("MalAccessToken")
+ .HasColumnType("TEXT");
+
+ b.Property("MalUserName")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property("ScrobbleEventGenerationRan")
+ .HasColumnType("TEXT");
+
+ 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.AppUserChapterRating", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("HasBeenRated")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("REAL");
+
+ b.Property("Review")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("ChapterId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserChapterRating");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserCollection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRating")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ 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("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastSyncUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("MissingSeriesFromSource")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("PrimaryColor")
+ .HasColumnType("TEXT");
+
+ b.Property("Promoted")
+ .HasColumnType("INTEGER");
+
+ b.Property("SecondaryColor")
+ .HasColumnType("TEXT");
+
+ b.Property("Source")
+ .HasColumnType("INTEGER");
+
+ b.Property("SourceUrl")
+ .HasColumnType("TEXT");
+
+ b.Property("Summary")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasColumnType("TEXT");
+
+ b.Property("TotalSourceCount")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserCollection");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserDashboardStream", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsProvided")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property("SmartFilterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("StreamType")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(4);
+
+ b.Property("Visible")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SmartFilterId");
+
+ b.HasIndex("Visible");
+
+ b.ToTable("AppUserDashboardStream");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserExternalSource", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ApiKey")
+ .HasColumnType("TEXT");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Host")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserExternalSource");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserOnDeckRemoval");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AllowAutomaticWebtoonReaderDetection")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(true);
+
+ b.Property("AniListScrobblingEnabled")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(true);
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("AutoCloseMenu")
+ .HasColumnType("INTEGER");
+
+ b.Property("BackgroundColor")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("#000000");
+
+ b.Property("BlurUnreadSummaries")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderFontFamily")
+ .HasColumnType("TEXT");
+
+ b.Property("BookReaderFontSize")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderImmersiveMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderLayoutMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderLineSpacing")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderMargin")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderTapToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderWritingStyle")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("BookThemeName")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("Dark");
+
+ b.Property("CollapseSeriesRelationships")
+ .HasColumnType("INTEGER");
+
+ b.Property("EmulateBook")
+ .HasColumnType("INTEGER");
+
+ b.Property("GlobalPageLayoutMode")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("LayoutMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("Locale")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("en");
+
+ b.Property("NoTransitions")
+ .HasColumnType("INTEGER");
+
+ b.Property("PageSplitOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("PdfScrollMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("PdfSpreadMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("PdfTheme")
+ .HasColumnType("INTEGER");
+
+ b.Property("PromptForDownloadSize")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReaderMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("ScalingOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShareReviews")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowScreenHints")
+ .HasColumnType("INTEGER");
+
+ b.Property("SwipeToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("ThemeId")
+ .HasColumnType("INTEGER");
+
+ b.Property("WantToReadSync")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(true);
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId")
+ .IsUnique();
+
+ b.HasIndex("ThemeId");
+
+ b.ToTable("AppUserPreferences");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserProgress", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookScrollId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PagesRead")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("ChapterId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserProgresses");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserRating", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("HasBeenRated")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("REAL");
+
+ b.Property("Review")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Tagline")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserRating");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RoleId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserSideNavStream", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ExternalSourceId")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsProvided")
+ .HasColumnType("INTEGER");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property("SmartFilterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("StreamType")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(5);
+
+ b.Property("Visible")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SmartFilterId");
+
+ b.HasIndex("Visible");
+
+ b.ToTable("AppUserSideNavStream");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserSmartFilter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Filter")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserSmartFilter");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserTableOfContent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookScrollId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PageNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .HasColumnType("TEXT");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("ChapterId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserTableOfContent");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserWantToRead", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserWantToRead");
+ });
+
+ modelBuilder.Entity("API.Entities.Chapter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRating")
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRatingLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("AlternateCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("AlternateNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("AlternateSeries")
+ .HasColumnType("TEXT");
+
+ b.Property("AverageExternalRating")
+ .HasColumnType("REAL");
+
+ b.Property("AvgHoursToRead")
+ .HasColumnType("REAL");
+
+ b.Property("CharacterLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("ColoristLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("Count")
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverArtistLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverImage")
+ .HasColumnType("TEXT");
+
+ b.Property("CoverImageLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("EditorLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("GenresLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("ISBN")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("");
+
+ b.Property("ISBNLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("ImprintLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("InkerLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsSpecial")
+ .HasColumnType("INTEGER");
+
+ b.Property("Language")
+ .HasColumnType("TEXT");
+
+ b.Property("LanguageLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LettererLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("LocationLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("MaxHoursToRead")
+ .HasColumnType("INTEGER");
+
+ b.Property("MaxNumber")
+ .HasColumnType("REAL");
+
+ b.Property("MinHoursToRead")
+ .HasColumnType("INTEGER");
+
+ b.Property("MinNumber")
+ .HasColumnType("REAL");
+
+ b.Property("Number")
+ .HasColumnType("TEXT");
+
+ b.Property("Pages")
+ .HasColumnType("INTEGER");
+
+ b.Property("PencillerLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("PrimaryColor")
+ .HasColumnType("TEXT");
+
+ b.Property("PublisherLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("Range")
+ .HasColumnType("TEXT");
+
+ b.Property("ReleaseDate")
+ .HasColumnType("TEXT");
+
+ b.Property("ReleaseDateLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("SecondaryColor")
+ .HasColumnType("TEXT");
+
+ b.Property