diff --git a/API.Benchmark/API.Benchmark.csproj b/API.Benchmark/API.Benchmark.csproj
index 2dcf08f32..de44f1fda 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..eb6e96040
--- /dev/null
+++ b/API.Benchmark/KoreaderHashBenchmark.cs
@@ -0,0 +1,44 @@
+using API.Helpers.Builders;
+using BenchmarkDotNet.Attributes;
+using BenchmarkDotNet.Order;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+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 6aefef830..a338dec98 100644
--- a/API.Tests/API.Tests.csproj
+++ b/API.Tests/API.Tests.csproj
@@ -6,12 +6,12 @@
-
+
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
@@ -35,4 +35,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/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/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs
index fcbfe8260..2a70ea79e 100644
--- a/API.Tests/Services/ScannerServiceTests.cs
+++ b/API.Tests/Services/ScannerServiceTests.cs
@@ -7,7 +7,10 @@ using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
+using System.Xml;
+using System.Xml.Serialization;
using API.Data;
+using API.Data.Metadata;
using API.Data.Repositories;
using API.Entities;
using API.Entities.Enums;
@@ -35,6 +38,7 @@ public class ScannerServiceTests : AbstractDbTest
private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests");
private readonly string _testcasesDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/TestCases");
private readonly string _imagePath = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/1x1.png");
+ private static readonly string[] ComicInfoExtensions = new[] { ".cbz", ".cbr", ".zip", ".rar" };
public ScannerServiceTests(ITestOutputHelper testOutputHelper)
{
@@ -125,9 +129,61 @@ public class ScannerServiceTests : AbstractDbTest
Assert.NotNull(postLib.Series.First().Volumes.FirstOrDefault(v => v.Chapters.FirstOrDefault(c => c.IsSpecial) != null));
}
- private async Task GenerateScannerData(string testcase)
+ ///
+ /// This is testing that if the first file is named A and has a localized name of B if all other files are named B, it should still group and name the series A
+ ///
+ [Fact]
+ public async Task ScanLibrary_LocalizedSeries()
{
- var testDirectoryPath = await GenerateTestDirectory(Path.Join(_testcasesDirectory, testcase));
+ const string testcase = "Series with Localized - Manga.json";
+
+ // Get the first file and generate a ComicInfo
+ var infos = new Dictionary();
+ infos.Add("My Dress-Up Darling v01.cbz", new ComicInfo()
+ {
+ Series = "My Dress-Up Darling",
+ LocalizedSeries = "Sono Bisque Doll wa Koi wo Suru"
+ });
+
+ var library = await GenerateScannerData(testcase, infos);
+
+
+ var scanner = CreateServices();
+ await scanner.ScanLibrary(library.Id);
+ var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
+
+ Assert.NotNull(postLib);
+ Assert.Single(postLib.Series);
+ Assert.Equal(3, postLib.Series.First().Volumes.Count);
+ }
+
+
+ ///
+ /// Files under a folder with a SP marker should group into one issue
+ ///
+ /// https://github.com/Kareadita/Kavita/issues/3299
+ [Fact]
+ public async Task ScanLibrary_ImageSeries_SpecialGrouping()
+ {
+ const string testcase = "Image Series with SP Folder - Manga.json";
+
+ var library = await GenerateScannerData(testcase);
+
+
+ var scanner = CreateServices();
+ await scanner.ScanLibrary(library.Id);
+ var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
+
+ Assert.NotNull(postLib);
+ Assert.Single(postLib.Series);
+ Assert.Equal(3, postLib.Series.First().Volumes.Count);
+ }
+
+
+ #region Setup
+ private async Task GenerateScannerData(string testcase, Dictionary comicInfos = null)
+ {
+ var testDirectoryPath = await GenerateTestDirectory(Path.Join(_testcasesDirectory, testcase), comicInfos);
var (publisher, type) = SplitPublisherAndLibraryType(Path.GetFileNameWithoutExtension(testcase));
@@ -148,11 +204,17 @@ public class ScannerServiceTests : AbstractDbTest
private ScannerService CreateServices()
{
- var ds = new DirectoryService(Substitute.For>(), new FileSystem());
- var mockReadingService = new MockReadingItemService(ds, Substitute.For());
+ var fs = new FileSystem();
+ var ds = new DirectoryService(Substitute.For>(), fs);
+ var archiveService = new ArchiveService(Substitute.For>(), ds,
+ Substitute.For(), Substitute.For());
+ var readingItemService = new ReadingItemService(archiveService, Substitute.For(),
+ Substitute.For(), ds, Substitute.For>());
+
+
var processSeries = new ProcessSeries(_unitOfWork, Substitute.For>(),
Substitute.For(),
- ds, Substitute.For(), mockReadingService, Substitute.For(),
+ ds, Substitute.For(), readingItemService, new FileService(fs),
Substitute.For(),
Substitute.For(),
Substitute.For(),
@@ -161,7 +223,7 @@ public class ScannerServiceTests : AbstractDbTest
var scanner = new ScannerService(_unitOfWork, Substitute.For>(),
Substitute.For(),
Substitute.For(), Substitute.For(), ds,
- mockReadingService, processSeries, Substitute.For());
+ readingItemService, processSeries, Substitute.For());
return scanner;
}
@@ -189,7 +251,7 @@ public class ScannerServiceTests : AbstractDbTest
- private async Task GenerateTestDirectory(string mapPath)
+ private async Task GenerateTestDirectory(string mapPath, Dictionary comicInfos = null)
{
// Read the map file
var mapContent = await File.ReadAllTextAsync(mapPath);
@@ -206,7 +268,7 @@ public class ScannerServiceTests : AbstractDbTest
Directory.CreateDirectory(testDirectory);
// Generate the files and folders
- await Scaffold(testDirectory, filePaths);
+ await Scaffold(testDirectory, filePaths, comicInfos);
_testOutputHelper.WriteLine($"Test Directory Path: {testDirectory}");
@@ -214,7 +276,7 @@ public class ScannerServiceTests : AbstractDbTest
}
- private async Task Scaffold(string testDirectory, List filePaths)
+ private async Task Scaffold(string testDirectory, List filePaths, Dictionary comicInfos = null)
{
foreach (var relativePath in filePaths)
{
@@ -229,9 +291,9 @@ public class ScannerServiceTests : AbstractDbTest
}
var ext = Path.GetExtension(fullPath).ToLower();
- if (new[] { ".cbz", ".cbr", ".zip", ".rar" }.Contains(ext))
+ if (ComicInfoExtensions.Contains(ext) && comicInfos != null && comicInfos.TryGetValue(Path.GetFileName(relativePath), out var info))
{
- CreateMinimalCbz(fullPath, includeMetadata: true);
+ CreateMinimalCbz(fullPath, info);
}
else
{
@@ -242,54 +304,44 @@ public class ScannerServiceTests : AbstractDbTest
}
}
- private void CreateMinimalCbz(string filePath, bool includeMetadata)
+ private void CreateMinimalCbz(string filePath, ComicInfo? comicInfo = null)
{
- var tempImagePath = _imagePath; // Assuming _imagePath is a valid path to the 1x1 image
-
using (var archive = ZipFile.Open(filePath, ZipArchiveMode.Create))
{
// Add the 1x1 image to the archive
- archive.CreateEntryFromFile(tempImagePath, "1x1.png");
+ archive.CreateEntryFromFile(_imagePath, "1x1.png");
- if (includeMetadata)
+ if (comicInfo != null)
{
- var comicInfo = GenerateComicInfo();
+ // Serialize ComicInfo object to XML
+ var comicInfoXml = SerializeComicInfoToXml(comicInfo);
+
+ // Create an entry for ComicInfo.xml in the archive
var entry = archive.CreateEntry("ComicInfo.xml");
using var entryStream = entry.Open();
using var writer = new StreamWriter(entryStream, Encoding.UTF8);
- writer.Write(comicInfo);
+
+ // Write the XML to the archive
+ writer.Write(comicInfoXml);
}
+
}
- Console.WriteLine($"Created minimal CBZ archive: {filePath} with{(includeMetadata ? "" : "out")} metadata.");
+ Console.WriteLine($"Created minimal CBZ archive: {filePath} with{(comicInfo != null ? "" : "out")} metadata.");
}
- private string GenerateComicInfo()
+
+ private static string SerializeComicInfoToXml(ComicInfo comicInfo)
{
- var comicInfo = new StringBuilder();
- comicInfo.AppendLine("");
- comicInfo.AppendLine("");
-
- // People Tags
- string[] people = { "Joe Shmo", "Tommy Two Hands"};
- string[] genres = { /* Your list of genres here */ };
-
- void AddRandomTag(string tagName, string[] choices)
+ var xmlSerializer = new XmlSerializer(typeof(ComicInfo));
+ using var stringWriter = new StringWriter();
+ using (var xmlWriter = XmlWriter.Create(stringWriter, new XmlWriterSettings { Indent = true, Encoding = new UTF8Encoding(false), OmitXmlDeclaration = false}))
{
- if (new Random().Next(0, 2) == 1) // 50% chance to include the tag
- {
- var selected = choices.OrderBy(x => Guid.NewGuid()).Take(new Random().Next(1, 5)).ToArray();
- comicInfo.AppendLine($" <{tagName}>{string.Join(", ", selected)}{tagName}>");
- }
+ xmlSerializer.Serialize(xmlWriter, comicInfo);
}
- foreach (var tag in new[] { "Writer", "Penciller", "Inker", "CoverArtist", "Publisher", "Character", "Imprint", "Colorist", "Letterer", "Editor", "Translator", "Team", "Location" })
- {
- AddRandomTag(tag, people);
- }
-
- AddRandomTag("Genre", genres);
- comicInfo.AppendLine("");
-
- return comicInfo.ToString();
+ // For the love of god, I spent 2 hours trying to get utf-8 with no BOM
+ return stringWriter.ToString().Replace("""""",
+ @"");
}
+ #endregion
}
diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder - Manga.json
new file mode 100644
index 000000000..62106703c
--- /dev/null
+++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder - Manga.json
@@ -0,0 +1,6 @@
+[
+ "My Dress-Up Darling/My Dress-Up Darling vol 1/0001.png",
+ "My Dress-Up Darling/My Dress-Up Darling vol 1/0002.png",
+ "My Dress-Up Darling/My Dress-Up Darling vol 2/0001.png",
+ "My Dress-Up Darling/Specials/My Dress-Up Darling SP01/0001.png"
+]
\ No newline at end of file
diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized - Manga.json
new file mode 100644
index 000000000..6495c294f
--- /dev/null
+++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized - Manga.json
@@ -0,0 +1,5 @@
+[
+ "My Dress-Up Darling/My Dress-Up Darling v01.cbz",
+ "My Dress-Up Darling/Sono Bisque Doll wa Koi wo Suru v02.cbz",
+ "My Dress-Up Darling/Sono Bisque Doll wa Koi wo Suru ch 10.cbz"
+]
\ No newline at end of file
diff --git a/API/API.csproj b/API/API.csproj
index 266d87192..3dcb7d92a 100644
--- a/API/API.csproj
+++ b/API/API.csproj
@@ -12,11 +12,11 @@
latestmajor
-
-
-
-
-
+
+
+
+
+
false
../favicon.ico
@@ -55,41 +55,41 @@
-
-
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
-
+
-
+
-
+
-
-
-
-
-
+
+
+
+
+
-
+
-
-
+
+
-
+
@@ -100,11 +100,11 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
-
+
-
+
diff --git a/API/Controllers/KoreaderController.cs b/API/Controllers/KoreaderController.cs
new file mode 100644
index 000000000..ec87f83f1
--- /dev/null
+++ b/API/Controllers/KoreaderController.cs
@@ -0,0 +1,99 @@
+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)
+ {
+ var userId = await GetUserId(apiKey);
+ await _koreaderService.SaveProgress(request, userId);
+
+ return Ok(new KoreaderProgressUpdateDto{ Document = request.Document, Timestamp = DateTime.UtcNow });
+ }
+
+
+ [HttpGet("{apiKey}/syncs/progress/{ebookHash}")]
+ public async Task> GetProgress(string apiKey, string ebookHash)
+ {
+ var userId = await GetUserId(apiKey);
+ var response = await _koreaderService.GetProgress(ebookHash, userId);
+ _logger.LogDebug("Koreader response progress: {Progress}", response.Progress);
+
+ return Ok(response);
+ }
+
+ 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/PersonController.cs b/API/Controllers/PersonController.cs
index fb18156ba..e5b9a99cc 100644
--- a/API/Controllers/PersonController.cs
+++ b/API/Controllers/PersonController.cs
@@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Mvc;
using Nager.ArticleNumber;
namespace API.Controllers;
+#nullable enable
public class PersonController : BaseApiController
{
@@ -39,11 +40,11 @@ public class PersonController : BaseApiController
}
///
- /// Returns a list of authors for browsing
+ /// Returns a list of authors & artists for browsing
///
///
///
- [HttpPost("authors")]
+ [HttpPost("all")]
public async Task>> GetAuthorsForBrowse([FromQuery] UserParams? userParams)
{
userParams ??= UserParams.Default;
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..156100ea5
--- /dev/null
+++ b/API/DTOs/Koreader/KoreaderProgressUpdateDto.cs
@@ -0,0 +1,9 @@
+using System;
+
+namespace API.DTOs.Koreader;
+
+public class KoreaderProgressUpdateDto
+{
+ public string Document { get; set; }
+ public DateTime Timestamp { get; set; }
+}
diff --git a/API/Data/Migrations/20241026114311_KoreaderHash.Designer.cs b/API/Data/Migrations/20241026114311_KoreaderHash.Designer.cs
new file mode 100644
index 000000000..1fbc03964
--- /dev/null
+++ b/API/Data/Migrations/20241026114311_KoreaderHash.Designer.cs
@@ -0,0 +1,3200 @@
+//
+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("20241026114311_KoreaderHash")]
+ partial class KoreaderHash
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "8.0.10");
+
+ modelBuilder.Entity("API.Entities.AppRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("API.Entities.AppUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRestriction")
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRestrictionIncludeUnknowns")
+ .HasColumnType("INTEGER");
+
+ b.Property("AniListAccessToken")
+ .HasColumnType("TEXT");
+
+ b.Property("ApiKey")
+ .HasColumnType("TEXT");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("ConfirmationToken")
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastActive")
+ .HasColumnType("TEXT");
+
+ b.Property("LastActiveUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("TEXT");
+
+ b.Property("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("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.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("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.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("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("SeriesGroup")
+ .HasColumnType("TEXT");
+
+ b.Property("SortOrder")
+ .HasColumnType("REAL");
+
+ b.Property("SortOrderLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("StoryArc")
+ .HasColumnType("TEXT");
+
+ b.Property("StoryArcNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("Summary")
+ .HasColumnType("TEXT");
+
+ b.Property("SummaryLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("TagsLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("TeamLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .HasColumnType("TEXT");
+
+ b.Property