diff --git a/API.Tests/Helpers/KoreaderHelperTests.cs b/API.Tests/Helpers/KoreaderHelperTests.cs index 6db2f8a42..1d4710f2c 100644 --- a/API.Tests/Helpers/KoreaderHelperTests.cs +++ b/API.Tests/Helpers/KoreaderHelperTests.cs @@ -7,73 +7,180 @@ namespace API.Tests.Helpers; public class KoreaderHelperTests { + #region UpdateProgressDto Tests [Theory] - [InlineData("/body/DocFragment[11]/body/div/a", 10, null)] - [InlineData("/body/DocFragment[1]/body/div/p[40]", 0, 40)] - public void GetEpubPositionDto(string koreaderPosition, int page, int? pNumber) + [InlineData("/body/DocFragment[11]/body/div/a", 10, null)] // Anchor tags return null BookScrollId + [InlineData("/body/DocFragment[1]/body/div/p[40]", 0, "//body/div/p[40]")] + [InlineData("/body/DocFragment[5]/body/section/div[2]", 4, "//body/section/div[2]")] + public void UpdateProgressDto_StandardXPath(string koreaderPosition, int expectedPage, string? expectedScrollId) { - var expected = EmptyProgressDto(); - expected.BookScrollId = pNumber.HasValue ? $"//BODY/DIV/P[{pNumber}]" : null; - expected.PageNum = page; var actual = EmptyProgressDto(); KoreaderHelper.UpdateProgressDto(actual, koreaderPosition); - Assert.Equal(expected.BookScrollId?.ToLowerInvariant(), actual.BookScrollId); - Assert.Equal(expected.PageNum, actual.PageNum); - } - - [Theory] - [InlineData("/body/DocFragment[8]/body/div/p[28]/text().264", 7, 28)] - public void GetEpubPositionDtoWithExtraXpath(string koreaderPosition, int page, int? pNumber) - { - var expected = EmptyProgressDto(); - expected.BookScrollId = pNumber.HasValue ? $"//BODY/DIV/P[{pNumber}]" : null; - expected.PageNum = page; - var actual = EmptyProgressDto(); - - KoreaderHelper.UpdateProgressDto(actual, koreaderPosition); - Assert.Equal(expected.BookScrollId?.ToLowerInvariant(), actual.BookScrollId); - Assert.Equal(expected.PageNum, actual.PageNum); + + Assert.Equal(expectedPage, actual.PageNum); + Assert.Equal(expectedScrollId, actual.BookScrollId); } [Theory] + [InlineData("/body/DocFragment[8]/body/div/p[28]/text().264", 7, "//body/div/p[28]")] [InlineData("/body/DocFragment[3]/body/h1/text().0", 2, "//body/h1")] - [InlineData("/body/DocFragment[10].0", 9, null)] [InlineData("/body/DocFragment[9]/body/p[52]/text().248", 8, "//body/p[52]")] - public void GetEpubPositionDto_UserSubmitted(string koreaderPosition, int page, string? convertedXpath) + [InlineData("/body/DocFragment[6]/body/div/span.0", 5, "//body/div/span")] // Trailing .0 stripped + public void UpdateProgressDto_WithTextOffsets(string koreaderPosition, int expectedPage, string? expectedScrollId) { - var expected = EmptyProgressDto(); - expected.PageNum = page; var actual = EmptyProgressDto(); KoreaderHelper.UpdateProgressDto(actual, koreaderPosition); - if (!string.IsNullOrEmpty(convertedXpath)) - { - Assert.Equal(convertedXpath, actual.BookScrollId); - } - Assert.Equal(expected.PageNum, actual.PageNum); + + Assert.Equal(expectedPage, actual.PageNum); + Assert.Equal(expectedScrollId, actual.BookScrollId); } [Theory] - [InlineData("//body/p[20]", 5, "/body/DocFragment[5]/body/p[20]")] - [InlineData(null, 10, "/body/DocFragment[10]/body/p[1]")] // I've not seen a null/just an "A" from Koreader in testing - public void GetKoreaderPosition(string? scrollId, int page, string koreaderPosition) + [InlineData("/body/DocFragment[10].0", 9, null)] // Short path - no scroll ID determinable + [InlineData("/body/DocFragment[5]", 4, null)] + [InlineData("/body/DocFragment[1]/body", 0, null)] // Too short for full path extraction + public void UpdateProgressDto_ShortPaths(string koreaderPosition, int expectedPage, string? expectedScrollId) + { + var actual = EmptyProgressDto(); + + KoreaderHelper.UpdateProgressDto(actual, koreaderPosition); + + Assert.Equal(expectedPage, actual.PageNum); + Assert.Equal(expectedScrollId, actual.BookScrollId); + } + + [Theory] + [InlineData("#_doc_fragment_10", 9, null)] + [InlineData("#_doc_fragment_1", 0, null)] + [InlineData("#_doc_fragment_10_ some_anchor", 9, null)] // With trailing anchor + [InlineData("#_doc_fragment10", 9, null)] // Legacy format without underscore + [InlineData("#_doc_fragment1", 0, null)] + public void UpdateProgressDto_HashFragmentFormat(string koreaderPosition, int expectedPage, string? expectedScrollId) + { + var actual = EmptyProgressDto(); + + KoreaderHelper.UpdateProgressDto(actual, koreaderPosition); + + Assert.Equal(expectedPage, actual.PageNum); + Assert.Equal(expectedScrollId, actual.BookScrollId); + } + + [Theory] + [InlineData("5", 4)] // Archive/PDF page number (1-indexed from KOReader) + [InlineData("1", 0)] + [InlineData("100", 99)] + public void UpdateProgressDto_NumericOnly(string koreaderPosition, int expectedPage) + { + var actual = EmptyProgressDto(); + + KoreaderHelper.UpdateProgressDto(actual, koreaderPosition); + + Assert.Equal(expectedPage, actual.PageNum); + } + + [Theory] + [InlineData("/body/DocFragment[11]/body/id(\"chapter1\")", 10, null)] // id() selectors not supported + [InlineData("/body/DocFragment[5]/body/div/id(\"section2\")/p[1]", 4, null)] + public void UpdateProgressDto_IdSelectors(string koreaderPosition, int expectedPage, string? expectedScrollId) + { + var actual = EmptyProgressDto(); + + KoreaderHelper.UpdateProgressDto(actual, koreaderPosition); + + Assert.Equal(expectedPage, actual.PageNum); + Assert.Equal(expectedScrollId, actual.BookScrollId); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void UpdateProgressDto_EmptyOrNull_NoChanges(string? koreaderPosition) + { + var actual = EmptyProgressDto(); + actual.PageNum = 5; + actual.BookScrollId = "//body/p[1]"; + + KoreaderHelper.UpdateProgressDto(actual, koreaderPosition!); + + // Should remain unchanged + Assert.Equal(5, actual.PageNum); + Assert.Equal("//body/p[1]", actual.BookScrollId); + } + + #endregion + + #region GetKoreaderPosition Tests + + [Theory] + [InlineData("//body/p[20]", 4, "/body/DocFragment[5]/body/p[20].0")] + [InlineData("//body/div/section[3]", 0, "/body/DocFragment[1]/body/div/section[3].0")] + [InlineData("//body/h1", 9, "/body/DocFragment[10]/body/h1.0")] + public void GetKoreaderPosition_WithScrollId(string? scrollId, int page, string expectedPosition) { var given = EmptyProgressDto(); given.BookScrollId = scrollId; given.PageNum = page; - Assert.Equal(koreaderPosition.ToUpperInvariant(), KoreaderHelper.GetKoreaderPosition(given).ToUpperInvariant()); + var result = KoreaderHelper.GetKoreaderPosition(given); + + Assert.Equal(expectedPosition, result, ignoreCase: true); } [Theory] - [InlineData("./Data/AesopsFables.epub", "8795ACA4BF264B57C1EEDF06A0CEE688")] - public void GetKoreaderHash(string filePath, string hash) + [InlineData(null, 9, "/body/DocFragment[10].0")] + [InlineData("", 0, "/body/DocFragment[1].0")] + [InlineData(null, 4, "/body/DocFragment[5].0")] + public void GetKoreaderPosition_NoScrollId_ReturnsFragmentOnly(string? scrollId, int page, string expectedPosition) { - Assert.Equal(KoreaderHelper.HashContents(filePath), hash); + var given = EmptyProgressDto(); + given.BookScrollId = scrollId; + given.PageNum = page; + + var result = KoreaderHelper.GetKoreaderPosition(given); + + Assert.Equal(expectedPosition, result, ignoreCase: true); } + [Theory] + [InlineData("id(\"chapter1\")", 5, "/body/DocFragment[6].0")] + [InlineData("id(\"h2\")", 10, "/body/DocFragment[11].0")] + public void GetKoreaderPosition_IdSelector_ReturnsFragmentOnly(string scrollId, int page, string expectedPosition) + { + var given = EmptyProgressDto(); + given.BookScrollId = scrollId; + given.PageNum = page; + + var result = KoreaderHelper.GetKoreaderPosition(given); + + Assert.Equal(expectedPosition, result, ignoreCase: true); + } + + #endregion + + #region HashContents Tests + + [Theory] + [InlineData("./Data/AesopsFables.epub", "8795ACA4BF264B57C1EEDF06A0CEE688")] + public void HashContents_ValidFile_ReturnsExpectedHash(string filePath, string expectedHash) + { + Assert.Equal(expectedHash, KoreaderHelper.HashContents(filePath)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("./Data/NonExistent.epub")] + public void HashContents_InvalidFile_ReturnsNull(string? filePath) + { + Assert.Null(KoreaderHelper.HashContents(filePath!)); + } + + #endregion + private static ProgressDto EmptyProgressDto() { return new ProgressDto diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index 3eb3e1d09..306f7c78f 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -21,6 +21,7 @@ using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; using Xunit.Abstractions; +using YamlDotNet.Core; namespace API.Tests.Services; @@ -1896,6 +1897,57 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb Assert.Equal("1", nextChapter.Range); } + [Fact] + public async Task GetContinuePoint_ShouldReturnFirstVolume_WhenHasSpecial_LightNovel() + { + var (unitOfWork, context, _) = await CreateDatabase(); + var readerService = Setup(unitOfWork); + + var library = new LibraryBuilder("Test Lib", LibraryType.LightNovel).Build(); + context.Library.Add(library); + await context.SaveChangesAsync(); + + var series = new SeriesBuilder("Test") + // Loose chapters + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapterNumber).WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapterNumber).WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder("12") + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapterNumber).WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder("99.9") + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapterNumber).WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, "Short Stories").WithIsSpecial(true) + .WithSortOrder(0).WithPages(1).Build()) + .Build()) + .WithLibraryId(library.Id) + .Build(); + + + context.Series.Add(series); + + context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await context.SaveChangesAsync(); + + var nextChapter = await readerService.GetContinuePoint(1, 1); + var volume = await context.Volume.FirstOrDefaultAsync(v => v.Id == nextChapter.VolumeId); + + Assert.Equal(1f, volume.MinNumber); + } + [Fact] public async Task GetContinuePoint_ShouldReturnFirstSpecial() { diff --git a/API.Tests/Services/ReadingHistoryServiceTests.cs b/API.Tests/Services/ReadingHistoryServiceTests.cs new file mode 100644 index 000000000..d0deda52e --- /dev/null +++ b/API.Tests/Services/ReadingHistoryServiceTests.cs @@ -0,0 +1,120 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.DTOs.Progress; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Progress; +using API.Helpers.Builders; +using API.Services.Reading; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; +using Xunit.Abstractions; + +namespace API.Tests.Services; + +public class ReadingHistoryServiceTests(ITestOutputHelper testOutputHelper) : AbstractDbTest(testOutputHelper) +{ + private ReadingHistoryService Setup(DataContext context) + { + return new ReadingHistoryService(context, Substitute.For>()); + } + + [Fact] + public async Task ActiveSession_DoNotCreateHistoryItems() + { + var (_, dataContext, _) = await CreateDatabase(); + var service = Setup(dataContext); + + // Setup data + var lib = await dataContext.Library.FirstAsync(); + lib.Series.Add(new SeriesBuilder("Test") + .WithVolume(new VolumeBuilder("1").WithChapter(new ChapterBuilder("1").WithPages(2).Build()).Build()) + .Build()); + + await dataContext.AppUser.AddAsync(new AppUser() { UserName = "Test" }); + + await dataContext.SaveChangesAsync(); + + // Create an active session dated for yesterday + var yesterday= DateTime.Now.Date.AddDays(-1); + var yesterdayUtc = DateTime.UtcNow.Date.AddDays(-1); + await dataContext.AppUserReadingSession.AddAsync(new AppUserReadingSession() + { + ActivityData = + [ + new AppUserReadingSessionActivityData(new ProgressDto() + { + ChapterId = 1, VolumeId = 1, LibraryId = 1, PageNum = 1, SeriesId = 1 + }, 1, MangaFormat.Archive) + ], + AppUserId = 1, + StartTime = yesterday, + StartTimeUtc = yesterdayUtc, + IsActive = true, + }); + + // Run the service + await service.AggregateYesterdaysActivity(); + + // Check that there are no history items + Assert.False(await dataContext.AppUserReadingHistory.AnyAsync()); + } + + + [Fact] + public async Task CreatesForYesterdaySessions() + { + var (_, dataContext, _) = await CreateDatabase(); + var service = Setup(dataContext); + + // Setup data + var lib = await dataContext.Library.FirstAsync(); + lib.Series.Add(new SeriesBuilder("Test") + .WithVolume(new VolumeBuilder("1").WithChapter(new ChapterBuilder("1").WithPages(2).Build()).Build()) + .Build()); + + await dataContext.AppUser.AddAsync(new AppUser() { UserName = "Test" }); + + await dataContext.SaveChangesAsync(); + + // Create an active session dated for yesterday + var yesterday= DateTime.Now.Date.AddDays(-1); + var yesterdayUtc = DateTime.UtcNow.Date.AddDays(-1); + var activityData = new AppUserReadingSessionActivityData(new ProgressDto() + { + ChapterId = 1, VolumeId = 1, LibraryId = 1, PageNum = 1, SeriesId = 1 + }, 1, MangaFormat.Archive); + + activityData.StartTime = yesterday; + activityData.StartTimeUtc = yesterdayUtc; + + await dataContext.AppUserReadingSession.AddAsync(new AppUserReadingSession() + { + ActivityData = + [ + activityData + ], + AppUserId = 1, + StartTime = yesterday, + StartTimeUtc = yesterdayUtc, + EndTime = yesterday.AddHours(1), + EndTimeUtc = yesterdayUtc.AddHours(1), + IsActive = false, + }); + + await dataContext.SaveChangesAsync(); + + // Run the service + await service.AggregateYesterdaysActivity(); + + // Check that there are no history items + var historyItems = await dataContext.AppUserReadingHistory.ToListAsync(); + Assert.Single(historyItems); + Assert.Single(historyItems[0].Data.Activities); + } +} + diff --git a/API/DTOs/Progress/DailyReadingDataDto.cs b/API/DTOs/Progress/DailyReadingDataDto.cs index 0f8616ac1..d742503cd 100644 --- a/API/DTOs/Progress/DailyReadingDataDto.cs +++ b/API/DTOs/Progress/DailyReadingDataDto.cs @@ -1,6 +1,11 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using API.Entities; +using API.Entities.Enums; +using API.Services; namespace API.DTOs.Progress; +#nullable enable public class DailyReadingDataDto { @@ -8,6 +13,46 @@ public class DailyReadingDataDto public int TotalPagesRead { get; set; } public int TotalWordsRead { get; set; } public int LongestSessionMinutes { get; set; } - public IList SeriesIds { get; set; } - public IList ChapterIds { get; set; } + + /// + /// Detailed breakdown by series/chapter read that day + /// + public List Activities { get; set; } = []; + + // Data may be deleted, these are legacy identifiers + public IList SeriesIds { get; set; } + public IList ChapterIds { get; set; } +} + +public class ReadingActivitySnapshotDto +{ + // Nullable FKs - null means entity was deleted + public int? SeriesId { get; set; } + public int? ChapterId { get; set; } + public int? VolumeId { get; set; } + public int? LibraryId { get; set; } + + // Denormalized metadata captured at read time + public string SeriesName { get; set; } + public string? LocalizedSeriesName { get; set; } + public string LibraryName { get; set; } + /// + /// Maps to + /// + public string ChapterRange { get; set; } + /// + /// Maps to + /// + public float VolumeNumber { get; set; } + + public MangaFormat Format { get; set; } + public LibraryType LibraryType { get; set; } + + // Reading metrics for this specific activity + public int PagesRead { get; set; } + public int WordsRead { get; set; } + public int MinutesRead { get; set; } + + public DateTime StartTimeUtc { get; set; } + public DateTime EndTimeUtc { get; set; } } diff --git a/API/Data/ManualMigrations/v0.8.9/MigrateBadKoreaderProgress.cs b/API/Data/ManualMigrations/v0.8.9/MigrateBadKoreaderProgress.cs index 2673526a5..79e73d0ce 100644 --- a/API/Data/ManualMigrations/v0.8.9/MigrateBadKoreaderProgress.cs +++ b/API/Data/ManualMigrations/v0.8.9/MigrateBadKoreaderProgress.cs @@ -26,8 +26,7 @@ public class MigrateBadKoreaderProgress : ManualMigration if (badProgressWithLibrary.Count == 0) return; - logger.LogInformation("[MigrateBadKoreaderProgress] Found {Count} progress records with LibraryId = 0, fixing...", - badProgressWithLibrary.Count); + logger.LogInformation("Found {Count} progress records with LibraryId = 0", badProgressWithLibrary.Count); foreach (var item in badProgressWithLibrary) { @@ -36,7 +35,6 @@ public class MigrateBadKoreaderProgress : ManualMigration await context.SaveChangesAsync(); - logger.LogInformation("[MigrateBadKoreaderProgress] Successfully fixed {Count} progress records", - badProgressWithLibrary.Count); + logger.LogInformation("Successfully fixed {Count} progress records", badProgressWithLibrary.Count); } } diff --git a/API/Data/Migrations/20260112165908_ReadingHistoryChanges.Designer.cs b/API/Data/Migrations/20260112165908_ReadingHistoryChanges.Designer.cs new file mode 100644 index 000000000..988d3823b --- /dev/null +++ b/API/Data/Migrations/20260112165908_ReadingHistoryChanges.Designer.cs @@ -0,0 +1,4479 @@ +// +using System; +using System.Collections.Generic; +using API.Data; +using API.Entities.MetadataMatching; +using API.Entities.Progress; +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("20260112165908_ReadingHistoryChanges")] + partial class ReadingHistoryChanges + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .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("IdentityProvider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + 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("OidcId") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .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.AppUserAnnotation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("CommentHtml") + .HasColumnType("TEXT"); + + b.Property("CommentPlainText") + .HasColumnType("TEXT"); + + b.Property("ContainsSpoiler") + .HasColumnType("INTEGER"); + + b.Property("Context") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingXPath") + .HasColumnType("TEXT"); + + b.Property("HighlightCount") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("Likes") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SelectedSlotIndex") + .HasColumnType("INTEGER"); + + b.Property("SelectedText") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("XPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserAnnotation"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("ImageOffset") + .HasColumnType("INTEGER"); + + 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.Property("XPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.HasIndex("AppUserId", "SeriesId") + .HasDatabaseName("IX_AppUserBookmark_AppUserId_SeriesId"); + + 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.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + 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.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .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("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.PrimitiveCollection("DeviceIds") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("LibraryIds") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("SeriesIds") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + 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("ChapterTitle") + .HasColumnType("TEXT"); + + 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("SelectedText") + .HasColumnType("TEXT"); + + 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("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + 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("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TitleName") + .HasDatabaseName("IX_Chapter_TitleName"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ClientDeviceHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CapturedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ClientInfo") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"UserAgent\":\"\",\"IpAddress\":\"\",\"AuthType\":0,\"ClientType\":0,\"AppVersion\":null,\"Browser\":null,\"BrowserVersion\":null,\"Platform\":0,\"DeviceType\":null,\"ScreenWidth\":null,\"ScreenHeight\":null,\"Orientation\":null,\"CapturedAt\":\"0001-01-01T00:00:00\"}"); + + b.Property("DeviceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.ToTable("ClientDeviceHistory"); + }); + + 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.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.EpubFont", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("EpubFont"); + }); + + 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.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DefaultLanguage") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("InheritWebLinksFromFirstChapter") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("RemovePrefixForSortName") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + 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("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("KoreaderHash") + .HasColumnType("TEXT"); + + 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.HasIndex("FilePath") + .HasDatabaseName("IX_MangaFile_FilePath"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + 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("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .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("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AgeRating") + .HasDatabaseName("IX_SeriesMetadata_AgeRating"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.HasIndex("SeriesId", "AgeRating") + .HasDatabaseName("IX_SeriesMetadata_SeriesId_AgeRating"); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.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.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.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableExtendedMetadataProcessing") + .HasColumnType("INTEGER"); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.Progress.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("TotalReads") + .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.Progress.AppUserReadingHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ClientInfoUsed") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Data") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"TotalMinutesRead\":0,\"TotalPagesRead\":0,\"TotalWordsRead\":0,\"LongestSessionMinutes\":0,\"Activities\":[],\"SeriesIds\":null,\"ChapterIds\":null}"); + + b.Property("DateUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("DateUtc") + .IsUnique(); + + b.ToTable("AppUserReadingHistory"); + }); + + modelBuilder.Entity("API.Entities.Progress.AppUserReadingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("EndTimeUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("StartTimeUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.HasIndex("AppUserId", "IsActive") + .HasDatabaseName("IX_AppUserReadingSession_AppUserId_IsActive"); + + b.HasIndex("IsActive", "LastModifiedUtc") + .HasDatabaseName("IX_AppUserReadingSession_IsActive_LastModifiedUtc"); + + b.ToTable("AppUserReadingSession"); + }); + + modelBuilder.Entity("API.Entities.Progress.AppUserReadingSessionActivityData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserReadingSessionId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("DeviceIds") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EndBookScrollId") + .HasColumnType("TEXT"); + + b.Property("EndPage") + .HasColumnType("INTEGER"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("EndTimeUtc") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("StartBookScrollId") + .HasColumnType("TEXT"); + + b.Property("StartPage") + .HasColumnType("INTEGER"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("StartTimeUtc") + .HasColumnType("TEXT"); + + b.Property("TotalPages") + .HasColumnType("INTEGER"); + + b.Property("TotalWords") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WordsRead") + .HasColumnType("INTEGER"); + + b.ComplexProperty(typeof(Dictionary), "ClientInfo", "API.Entities.Progress.AppUserReadingSessionActivityData.ClientInfo#ClientInfoData", b1 => + { + b1.Property("AppVersion"); + + b1.Property("AuthType"); + + b1.Property("Browser"); + + b1.Property("BrowserVersion"); + + b1.Property("CapturedAt"); + + b1.Property("ClientType"); + + b1.Property("DeviceType"); + + b1.Property("IpAddress") + .IsRequired(); + + b1.Property("Orientation"); + + b1.Property("Platform"); + + b1.Property("ScreenHeight"); + + b1.Property("ScreenWidth"); + + b1.Property("UserAgent") + .IsRequired(); + + b1.ToJson("ClientInfo"); + }); + + b.HasKey("Id"); + + b.HasIndex("AppUserReadingSessionId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.HasIndex("StartTimeUtc", "LibraryId") + .HasDatabaseName("IX_ActivityData_StartTimeUtc_LibraryId"); + + b.ToTable("AppUserReadingSessionActivityData"); + }); + + 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") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .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.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .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("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId") + .HasDatabaseName("IX_Series_LibraryId"); + + b.HasIndex("NormalizedName") + .HasDatabaseName("IX_Series_NormalizedName"); + + 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("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .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("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + 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.User.AppUserAuthKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("LastAccessedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("AppUserAuthKey"); + }); + + modelBuilder.Entity("API.Entities.User.AppUserChapterRating", 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("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + 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.User.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("BookReaderHighlightSlots") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + 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("ColorScapeEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CustomKeyBinds") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{}"); + + b.Property("DataSaver") + .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("OpdsPreferences") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"EmbedProgressIndicator\":true,\"IncludeContinueFrom\":true}"); + + 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("PromptForRereadsAfter") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30); + + 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("SocialPreferences") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"ShareReviews\":false,\"ShareAnnotations\":false,\"ViewOtherAnnotations\":false,\"SocialLibraries\":[],\"SocialMaxAgeRating\":-1,\"SocialIncludeUnknowns\":true,\"ShareProfile\":false}"); + + 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.User.ClientDevice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CurrentClientInfo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"UserAgent\":\"\",\"IpAddress\":\"\",\"AuthType\":0,\"ClientType\":0,\"AppVersion\":null,\"Browser\":null,\"BrowserVersion\":null,\"Platform\":0,\"DeviceType\":null,\"ScreenWidth\":null,\"ScreenHeight\":null,\"Orientation\":null,\"CapturedAt\":\"0001-01-01T00:00:00\"}"); + + b.Property("DeviceFingerprint") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstSeenUtc") + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("UiFingerprint") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ClientDevice"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + 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("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + 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("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("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FriendlyName") + .HasColumnType("TEXT"); + + b.Property("Xml") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + 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("API.Entities.AppUserAnnotation", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Annotations") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .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("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .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.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.ClientDeviceHistory", b => + { + b.HasOne("API.Entities.User.ClientDevice", "Device") + .WithMany("History") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + }); + + 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.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .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.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .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.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.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("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.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.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.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Progress.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.Progress.AppUserReadingHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingHistory") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.Progress.AppUserReadingSession", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingSessions") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.Progress.AppUserReadingSessionActivityData", b => + { + b.HasOne("API.Entities.Progress.AppUserReadingSession", "ReadingSession") + .WithMany("ActivityData") + .HasForeignKey("AppUserReadingSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .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("Library"); + + b.Navigation("ReadingSession"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + 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.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.User.AppUserAuthKey", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("AuthKeys") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.User.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.User.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.User.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.User.ClientDevice", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ClientDevices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + 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("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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("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("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("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("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Annotations"); + + b.Navigation("AuthKeys"); + + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("ClientDevices"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingHistory"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ReadingSessions"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences") + .IsRequired(); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.Progress.AppUserReadingSession", b => + { + b.Navigation("ActivityData"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.User.ClientDevice", b => + { + b.Navigation("History"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20260112165908_ReadingHistoryChanges.cs b/API/Data/Migrations/20260112165908_ReadingHistoryChanges.cs new file mode 100644 index 000000000..e22cb1a61 --- /dev/null +++ b/API/Data/Migrations/20260112165908_ReadingHistoryChanges.cs @@ -0,0 +1,52 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class ReadingHistoryChanges : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CreatedUtc", + table: "AppUserReadingHistory"); + + migrationBuilder.AlterColumn( + name: "Data", + table: "AppUserReadingHistory", + type: "TEXT", + nullable: true, + defaultValue: "{\"TotalMinutesRead\":0,\"TotalPagesRead\":0,\"TotalWordsRead\":0,\"LongestSessionMinutes\":0,\"Activities\":[],\"SeriesIds\":null,\"ChapterIds\":null}", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true, + oldDefaultValue: "{\"TotalMinutesRead\":0,\"TotalPagesRead\":0,\"TotalWordsRead\":0,\"LongestSessionMinutes\":0,\"SeriesIds\":null,\"ChapterIds\":null}"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Data", + table: "AppUserReadingHistory", + type: "TEXT", + nullable: true, + defaultValue: "{\"TotalMinutesRead\":0,\"TotalPagesRead\":0,\"TotalWordsRead\":0,\"LongestSessionMinutes\":0,\"SeriesIds\":null,\"ChapterIds\":null}", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true, + oldDefaultValue: "{\"TotalMinutesRead\":0,\"TotalPagesRead\":0,\"TotalWordsRead\":0,\"LongestSessionMinutes\":0,\"Activities\":[],\"SeriesIds\":null,\"ChapterIds\":null}"); + + migrationBuilder.AddColumn( + name: "CreatedUtc", + table: "AppUserReadingHistory", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index cc89fb385..6c8d15a19 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -2125,13 +2125,10 @@ namespace API.Data.Migrations .HasColumnType("TEXT") .HasDefaultValue("[]"); - b.Property("CreatedUtc") - .HasColumnType("TEXT"); - b.Property("Data") .ValueGeneratedOnAdd() .HasColumnType("TEXT") - .HasDefaultValue("{\"TotalMinutesRead\":0,\"TotalPagesRead\":0,\"TotalWordsRead\":0,\"LongestSessionMinutes\":0,\"SeriesIds\":null,\"ChapterIds\":null}"); + .HasDefaultValue("{\"TotalMinutesRead\":0,\"TotalPagesRead\":0,\"TotalWordsRead\":0,\"LongestSessionMinutes\":0,\"Activities\":[],\"SeriesIds\":null,\"ChapterIds\":null}"); b.Property("DateUtc") .HasColumnType("TEXT"); diff --git a/API/Entities/Progress/AppUserReadingHistory.cs b/API/Entities/Progress/AppUserReadingHistory.cs index 71a8be214..009687732 100644 --- a/API/Entities/Progress/AppUserReadingHistory.cs +++ b/API/Entities/Progress/AppUserReadingHistory.cs @@ -13,11 +13,11 @@ public class AppUserReadingHistory { public int Id { get; set; } public DateTime DateUtc { get; set; } - public DateTime CreatedUtc { get; set; } /// /// JSON Column /// public DailyReadingDataDto Data { get; set; } + /// /// JSON Column /// diff --git a/API/Entities/Progress/AppUserReadingSessionActivityData.cs b/API/Entities/Progress/AppUserReadingSessionActivityData.cs index 7a184678e..fae6d1c69 100644 --- a/API/Entities/Progress/AppUserReadingSessionActivityData.cs +++ b/API/Entities/Progress/AppUserReadingSessionActivityData.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using API.DTOs.Progress; using API.Entities.Enums; using Microsoft.EntityFrameworkCore; @@ -54,4 +55,29 @@ public class AppUserReadingSessionActivityData /// /// JSON Column public List DeviceIds { get; set; } = []; + + public AppUserReadingSessionActivityData() + { + } + + public AppUserReadingSessionActivityData(ProgressDto dto, int startPage, MangaFormat format) + { + ChapterId = dto.ChapterId; + VolumeId = dto.VolumeId; + SeriesId = dto.SeriesId; + LibraryId = dto.LibraryId; + StartPage = startPage; + StartBookScrollId = dto.BookScrollId; + EndPage = dto.PageNum; + StartTime = DateTime.Now; + StartTimeUtc = DateTime.UtcNow; + EndTime = null; + EndTimeUtc = null; + PagesRead = 0; + WordsRead = 0; + ClientInfo = null; + DeviceIds = []; + Format = format; + + } } diff --git a/API/Entities/Volume.cs b/API/Entities/Volume.cs index 348f8bed7..d5e9cf1c2 100644 --- a/API/Entities/Volume.cs +++ b/API/Entities/Volume.cs @@ -18,7 +18,7 @@ public class Volume : IEntityDate, IHasReadTimeEstimate, IHasCoverImage /// public string LookupName { get; set; } /// - /// The minimum number in the Name field in Int form + /// The minimum number in the Name field in Int form. NOTE: For books, this is always 0 which will break logic. Do not use. /// /// Removed in v0.7.13.8, this was an int and we need the ability to have 0.5 volumes render on the UI [Obsolete("Use MinNumber and MaxNumber instead")] diff --git a/API/Extensions/QueryExtensions/ChapterQueryExtensions.cs b/API/Extensions/QueryExtensions/ChapterQueryExtensions.cs index bdb556b6c..44a2f2a68 100644 --- a/API/Extensions/QueryExtensions/ChapterQueryExtensions.cs +++ b/API/Extensions/QueryExtensions/ChapterQueryExtensions.cs @@ -1,6 +1,7 @@ using System.Linq; using API.Entities; using API.Services.Tasks.Scanner.Parser; +using Microsoft.EntityFrameworkCore; namespace API.Extensions.QueryExtensions; @@ -9,16 +10,17 @@ public static class ChapterQueryExtensions public static IOrderedQueryable ApplyDefaultChapterOrdering(this IQueryable query) { return query + .Include(c => c.Volume) .OrderBy(c => - // Priority 1: Regular volumes (not loose leaf, not special) - c.Volume.Number == Parser.LooseLeafVolumeNumber || - c.Volume.Number == Parser.SpecialVolumeNumber ? 1 : 0) + // Priority 1: Regular volumes (not loose-leaf, not special) + c.Volume.MinNumber == Parser.LooseLeafVolumeNumber || + c.Volume.MinNumber == Parser.SpecialVolumeNumber ? 1 : 0) .ThenBy(c => // Priority 2: Loose leaf over specials - c.Volume.Number == Parser.SpecialVolumeNumber ? 1 : 0) + c.Volume.MinNumber == Parser.SpecialVolumeNumber ? 1 : 0) // Priority 3: Non-special chapters .ThenBy(c => c.IsSpecial ? 1 : 0) - .ThenBy(c => c.Volume.Number) + .ThenBy(c => c.Volume.MinNumber) .ThenBy(c => c.SortOrder); } } diff --git a/API/Helpers/KoreaderHelper.cs b/API/Helpers/KoreaderHelper.cs index b387acf6d..1da4a1d63 100644 --- a/API/Helpers/KoreaderHelper.cs +++ b/API/Helpers/KoreaderHelper.cs @@ -1,6 +1,7 @@ using API.DTOs.Progress; using System; using System.IO; +using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; @@ -21,6 +22,13 @@ public static partial class KoreaderHelper [GeneratedRegex(@"^\d+$")] private static partial Regex JustNumber(); + /// + /// Matches #_doc_fragment_10, #_doc_fragment_10_ some_anchor, #_doc_fragment10, number captured in Group 1 + /// + /// + [GeneratedRegex(@"^#_doc_fragment_?(\d+)")] + private static partial Regex DocFragmentHashRegex(); + /// /// Hashes the document according to a custom Koreader hashing algorithm. /// Look at the util.partialMD5 method in the attached link. @@ -78,15 +86,18 @@ public static partial class KoreaderHelper public static void UpdateProgressDto(ProgressDto progress, string koreaderPosition) { - // #_doc_fragment26 - if (koreaderPosition.StartsWith("#_doc_fragment")) + if (string.IsNullOrWhiteSpace(koreaderPosition)) return; + + // Handle: #_doc_fragment_26, #_doc_fragment26, #_doc_fragment_10_ some_anchor + var hashMatch = DocFragmentHashRegex().Match(koreaderPosition); + if (hashMatch.Success) { - var docNumber = koreaderPosition.Replace("#_doc_fragment", string.Empty); - progress.PageNum = int.Parse(docNumber) - 1; + progress.PageNum = int.Parse(hashMatch.Groups[1].Value) - 1; + progress.BookScrollId = null; return; } - // Check if koreaderPosition is just a number, this indicates an Archive + // Check if koreaderPosition is just a number, this indicates an Archive/PDF if (JustNumber().IsMatch(koreaderPosition)) { progress.PageNum = int.Parse(koreaderPosition) - 1; @@ -97,7 +108,7 @@ public static partial class KoreaderHelper if (path.Length < 6) { // Handle cases like: /body/DocFragment[10].0 - if (path.Length == 3) + if (path.Length >= 3) { progress.PageNum = GetPageNumber(path); } @@ -109,10 +120,21 @@ public static partial class KoreaderHelper var lastPart = koreaderPosition.Split("/body/")[^1]; var lastTag = path[5].ToUpper(); - // If lastPart ends in a .Decimal, remove it as it's not a valid xpath + // Remove trailing position indicators like .0, /text()[1].42 lastPart = lastPart.Split("/text()")[0]; - if (lastTag == "A") + // Also strip trailing .N position markers + if (lastPart.Contains('.') && char.IsDigit(lastPart[^1])) + { + var dotIndex = lastPart.LastIndexOf('.'); + if (dotIndex > 0 && lastPart[(dotIndex + 1)..].All(char.IsDigit)) + { + lastPart = lastPart[..dotIndex]; + } + } + + // Skip anchor tags and id() selectors - can't reliably scroll to these + if (lastTag == "A" || lastPart.Contains("id(", StringComparison.InvariantCultureIgnoreCase) || lastTag.StartsWith("id(", StringComparison.InvariantCultureIgnoreCase)) { progress.BookScrollId = null; } @@ -145,11 +167,33 @@ public static partial class KoreaderHelper /// public static string GetKoreaderPosition(ProgressDto progressDto) { - var targetPath = !string.IsNullOrEmpty(progressDto.BookScrollId) - ? progressDto.BookScrollId.Replace("//body/", string.Empty, StringComparison.InvariantCultureIgnoreCase) - : "p[1]"; // Default to first paragraph if unknown - // Add 1 back to match KOReader's 1-based indexing - return $"/body/DocFragment[{progressDto.PageNum + 1}]/body/{targetPath}"; + var fragmentIndex = progressDto.PageNum + 1; + + if (string.IsNullOrEmpty(progressDto.BookScrollId)) + { + // No scroll position - point to start of fragment + // .0 is the character offset (start of element) + return $"/body/DocFragment[{fragmentIndex}].0"; + } + + + var targetPath = progressDto.BookScrollId + .Replace("//body/", string.Empty, StringComparison.InvariantCultureIgnoreCase); + + // KOReader can't handle id() XPath selectors - just return the base path + if (targetPath.StartsWith("id(", StringComparison.OrdinalIgnoreCase)) + { + return $"/body/DocFragment[{fragmentIndex}].0"; + } + + // Append .0 offset if the path doesn't already have a position marker + var fullPath = $"/body/DocFragment[{fragmentIndex}]/body/{targetPath}"; + if (!fullPath.Contains("/text()") && !fullPath.EndsWith(".0")) + { + fullPath += ".0"; + } + + return fullPath; } } diff --git a/API/Middleware/AuthKeyAuthenticationHandler.cs b/API/Middleware/AuthKeyAuthenticationHandler.cs index d0a73755b..e77770404 100644 --- a/API/Middleware/AuthKeyAuthenticationHandler.cs +++ b/API/Middleware/AuthKeyAuthenticationHandler.cs @@ -103,6 +103,10 @@ private readonly IUnitOfWork _unitOfWork; return AuthenticateResult.Success(ticket); } + catch (OperationCanceledException) + { + return AuthenticateResult.Fail("Auth Key authentication failed"); + } catch (Exception ex) { Logger.LogError(ex, "Auth Key authentication failed"); diff --git a/API/Middleware/DeviceTrackingMiddleware.cs b/API/Middleware/DeviceTrackingMiddleware.cs index 28b3194e0..c32d93048 100644 --- a/API/Middleware/DeviceTrackingMiddleware.cs +++ b/API/Middleware/DeviceTrackingMiddleware.cs @@ -51,6 +51,10 @@ public class DeviceTrackingMiddleware(RequestDelegate next, ILogger logger /// public async Task GetContinuePoint(int seriesId, int userId) { - var hasProgress = await unitOfWork.AppUserProgressRepository.AnyUserProgressForSeriesAsync(seriesId, userId); - if (!hasProgress) - { - // Get first chapter only - return await unitOfWork.ChapterRepository.GetFirstChapterForSeriesAsync(seriesId, userId); - } + // Since the first chapter has progress already on it, we can check if there is any progress and if not, return that chapter + var firstChapter = await unitOfWork.ChapterRepository.GetFirstChapterForSeriesAsync(seriesId, userId); + if (firstChapter is { PagesRead: 0 }) return firstChapter; var currentlyReading = await unitOfWork.ChapterRepository.GetCurrentlyReadingChapterAsync(seriesId, userId); - - if (currentlyReading != null) - { - return currentlyReading; - } + if (currentlyReading != null) return currentlyReading; var volumes = (await unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId, VolumeIncludes.Files)).ToList(); diff --git a/API/Services/Reading/ReadingHistoryService.cs b/API/Services/Reading/ReadingHistoryService.cs index d9bd06e1b..6b7330ca9 100644 --- a/API/Services/Reading/ReadingHistoryService.cs +++ b/API/Services/Reading/ReadingHistoryService.cs @@ -4,8 +4,8 @@ using System.Linq; using System.Threading.Tasks; using API.Data; using API.DTOs.Progress; +using API.Entities.Enums; using API.Entities.Progress; -using Hangfire; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -22,6 +22,9 @@ public class ReadingHistoryService : IReadingHistoryService private readonly DataContext _context; private readonly ILogger _logger; + private sealed record ChapterMetadata(int Id, string? Range, float VolumeNumber, string SeriesName, string? LocalizedSeriesName, string LibraryName, LibraryType LibraryType); + private sealed record SeriesMetadata(int Id, string Name, string? LocalizedName, string LibraryName, LibraryType LibraryType); + public ReadingHistoryService(DataContext context, ILogger logger) { _context = context; @@ -30,117 +33,183 @@ public class ReadingHistoryService : IReadingHistoryService public async Task AggregateYesterdaysActivity() { - var yesterday = DateTime.Today.AddDays(-1); var yesterdayUtc = DateTime.UtcNow.Date.AddDays(-1); + var startUtc = yesterdayUtc; + var endUtc = yesterdayUtc.AddDays(1).AddTicks(-1); - // Define precise boundaries for yesterday - var yesterdayStart = yesterday; // 2025-10-22 00:00:00.000 - var yesterdayEnd = yesterday.AddDays(1).AddTicks(-1); // 2025-10-22 23:59:59.9999999 + var usersToProcess = await GetUsersPendingAggregation(startUtc, endUtc, yesterdayUtc); - // First - Validate that all sessions are closed, if not, reschedule ourselves for 10 mins in future - if (await _context.AppUserReadingSession.AnyAsync(s => s.IsActive || s.EndTime == null)) + foreach (var userId in usersToProcess) { - _logger.LogWarning("Not all reading sessions are closed, rescheduling for 10 minutes"); - BackgroundJob.Schedule(() => AggregateYesterdaysActivity(), TimeSpan.FromMinutes(10)); - } - - // Second - Validate we haven't already created a ReadingHistory for yesterday - var existingHistoryUserIds = await _context.AppUserReadingHistory - .Where(h => h.DateUtc == yesterdayUtc) - .Select(h => h.AppUserId) - .ToListAsync(); - - if (existingHistoryUserIds.Count != 0) - { - _logger.LogInformation("Reading history already exists for {Count} users on {Date}", - existingHistoryUserIds.Count, yesterday); - return; - } - - // Third - Get all closed sessions from yesterday using precise boundaries - var yesterdaySessions = await _context.AppUserReadingSession - .Where(s => !s.IsActive && s.EndTime.HasValue) - .Where(s => s.StartTime >= yesterdayStart && s.StartTime <= yesterdayEnd) - .Include(s => s.ActivityData) - .ToListAsync(); - - if (yesterdaySessions.Count == 0) - { - _logger.LogInformation("No reading sessions found for {Date}", yesterday); - return; - } - - // Fourth - Group by user and aggregate - var userGroups = yesterdaySessions.GroupBy(s => s.AppUserId); - var userCount = 0; - - foreach (var userGroup in userGroups) - { - var userId = userGroup.Key; - var sessions = userGroup.ToList(); - - // Calculate aggregates - var totalMinutes = 0; - var totalPages = 0; - var totalWords = 0; - var longestSessionMinutes = 0; - var seriesIds = new List(); - var chapterIds = new List(); - - var devicesUsed = sessions - .SelectMany(s => s.ActivityData) - .Select(a => a.ClientInfo) - .Where(c => c != null) - .DistinctBy(c => new { c.UserAgent, c.IpAddress, c.ClientType, c.Platform, c.DeviceType }) - .ToList(); - - foreach (var session in sessions) - { - if (session.EndTime.HasValue) - { - var sessionMinutes = (int)(session.EndTime.Value - session.StartTime).TotalMinutes; - totalMinutes += sessionMinutes; - longestSessionMinutes = Math.Max(longestSessionMinutes, sessionMinutes); - } - - // Parse ActivityData JSON - foreach (var activity in session.ActivityData) - { - totalPages += activity.PagesRead; - totalWords += activity.WordsRead; - seriesIds.Add(activity.SeriesId); - chapterIds.Add(activity.ChapterId); - } - } - - var dailyData = new DailyReadingDataDto - { - TotalMinutesRead = totalMinutes, - TotalPagesRead = totalPages, - TotalWordsRead = totalWords, - LongestSessionMinutes = longestSessionMinutes, - SeriesIds = seriesIds.Distinct().ToList(), - ChapterIds = chapterIds.Distinct().ToList() - }; - - // Create ReadingHistory record - var history = new AppUserReadingHistory - { - AppUserId = userId, - DateUtc = yesterdayUtc, - Data = dailyData, - CreatedUtc = DateTime.UtcNow, - ClientInfoUsed = devicesUsed - }; - - _context.AppUserReadingHistory.Add(history); - userCount++; + await AggregateUserActivity(userId, startUtc, endUtc, yesterdayUtc); } await _context.SaveChangesAsync(); + } - _logger.LogInformation("Aggregated reading history for {UserCount} users on {Date}", - userCount, yesterday); + private async Task> GetUsersPendingAggregation(DateTime start, DateTime end, DateTime reportDate) + { + var needAggregationUserIds = await _context.AppUserReadingSession + .Where(s => s.StartTime >= start && s.StartTime <= end) + .Where(s => !s.IsActive && s.EndTime != null) + .Select(s => s.AppUserId) + .Distinct() + .ToListAsync(); + var alreadyHasHistoryUserIds = await _context.AppUserReadingHistory + .Where(h => h.DateUtc == reportDate) + .Select(h => h.AppUserId) + .ToListAsync(); + + return needAggregationUserIds.Except(alreadyHasHistoryUserIds).ToList(); + } + + private async Task AggregateUserActivity(int userId, DateTime start, DateTime end, DateTime reportDate) + { + var sessions = await _context.AppUserReadingSession + .Include(s => s.ActivityData) + .Where(s => s.AppUserId == userId && + s.StartTime >= start && s.StartTime <= end && + !s.IsActive && s.EndTime != null) + .ToListAsync(); + + if (sessions.Count == 0) return; + + var chapterMeta = await GetChapterMetadata(sessions); + var seriesMeta = await GetSeriesMetadata(sessions); + + var dailyData = CalculateDailyData(sessions, chapterMeta, seriesMeta); + + _context.AppUserReadingHistory.Add(new AppUserReadingHistory + { + AppUserId = userId, + DateUtc = reportDate, + ClientInfoUsed = ExtractClientInfo(sessions), + Data = dailyData + }); + } + + private async Task> GetChapterMetadata(List sessions) + { + var ids = sessions.SelectMany(s => s.ActivityData.Select(ad => ad.ChapterId)).Distinct().ToList(); + return await _context.Chapter + .Where(c => ids.Contains(c.Id)) + .Select(c => new ChapterMetadata( + c.Id, c.Range, c.Volume.MinNumber, c.Volume.Series.Name, + c.Volume.Series.LocalizedName, c.Volume.Series.Library.Name, + c.Volume.Series.Library.Type)) + .ToDictionaryAsync(c => c.Id); + } + + private async Task> GetSeriesMetadata(List sessions) + { + var ids = sessions.SelectMany(s => s.ActivityData.Select(ad => ad.SeriesId)).Distinct().ToList(); + return await _context.Series + .Where(s => ids.Contains(s.Id)) + .Select(s => new SeriesMetadata(s.Id, s.Name, s.LocalizedName, s.Library.Name, s.Library.Type)) + .ToDictionaryAsync(s => s.Id); + } + + private static DailyReadingDataDto CalculateDailyData(List sessions, + Dictionary chapterMeta, Dictionary seriesMeta) + { + var totalMinutes = 0; + var totalPages = 0; + var totalWords = 0; + var longestSession = 0; + var seriesIds = new HashSet(); + var chapterIds = new HashSet(); + var activities = new List(); + + foreach (var session in sessions) + { + var duration = (int)(session.EndTime!.Value - session.StartTime).TotalMinutes; + totalMinutes += duration; + longestSession = Math.Max(longestSession, duration); + + foreach (var activity in session.ActivityData) + { + totalPages += activity.PagesRead; + totalWords += activity.WordsRead; + chapterIds.Add(activity.ChapterId); + seriesIds.Add(activity.SeriesId); + + activities.Add(MapToSnapshot(activity, chapterMeta, seriesMeta)); + } + } + + return new DailyReadingDataDto + { + TotalMinutesRead = totalMinutes, + TotalPagesRead = totalPages, + TotalWordsRead = totalWords, + LongestSessionMinutes = longestSession, + SeriesIds = seriesIds.Cast().ToList(), + ChapterIds = chapterIds.Cast().ToList(), + Activities = activities + }; + } + + private static ReadingActivitySnapshotDto MapToSnapshot( AppUserReadingSessionActivityData activity, + Dictionary chapterLookup, Dictionary seriesLookup) + { + var minutesRead = activity.EndTimeUtc.HasValue + ? (int)(activity.EndTimeUtc.Value - activity.StartTimeUtc).TotalMinutes + : 0; + + var snapshot = new ReadingActivitySnapshotDto + { + ChapterId = activity.ChapterId, + VolumeId = activity.VolumeId, + SeriesId = activity.SeriesId, + LibraryId = activity.LibraryId, + Format = activity.Format, + PagesRead = activity.PagesRead, + WordsRead = activity.WordsRead, + MinutesRead = minutesRead, + StartTimeUtc = activity.StartTimeUtc, + EndTimeUtc = activity.EndTimeUtc ?? activity.StartTimeUtc, + + // Set defaults for required strings + SeriesName = string.Empty, + LibraryName = string.Empty, + ChapterRange = string.Empty + }; + + if (chapterLookup.TryGetValue(activity.ChapterId, out var c)) + { + snapshot.SeriesName = c.SeriesName; + snapshot.LocalizedSeriesName = c.LocalizedSeriesName; + snapshot.ChapterRange = c.Range ?? string.Empty; + snapshot.VolumeNumber = c.VolumeNumber; + snapshot.LibraryName = c.LibraryName; + snapshot.LibraryType = c.LibraryType; + } + else if (seriesLookup.TryGetValue(activity.SeriesId, out var s)) + { + snapshot.SeriesName = s.Name; + snapshot.LocalizedSeriesName = s.LocalizedName; + snapshot.LibraryName = s.LibraryName; + snapshot.LibraryType = s.LibraryType; + snapshot.ChapterRange = "[Deleted]"; + } + else + { + snapshot.SeriesName = "[Deleted Data]"; + snapshot.ChapterRange = "[Deleted]"; + } + + return snapshot; + } + + private static List ExtractClientInfo(List sessions) + { + return sessions + .SelectMany(s => s.ActivityData) + .Select(a => a.ClientInfo) + .Where(c => c != null) + .Select(c => c!) + .DistinctBy(c => new { c.UserAgent, c.IpAddress, c.Platform }) + .ToList(); } } diff --git a/API/Services/Reading/ReadingSessionService.cs b/API/Services/Reading/ReadingSessionService.cs index 3e4770786..9fe5cfc08 100644 --- a/API/Services/Reading/ReadingSessionService.cs +++ b/API/Services/Reading/ReadingSessionService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -32,6 +33,7 @@ public sealed class ReadingSessionService : IReadingSessionService, IDisposable, private readonly TimeSpan _pollInterval; private readonly Timer _cleanupTimer; private readonly SemaphoreSlim _cleanupLock = new(1, 1); + private static readonly ConcurrentDictionary UserLocks = new(); private bool _disposed; private static readonly HybridCacheEntryOptions ChapterFormatCacheOptions = new() @@ -63,22 +65,35 @@ public sealed class ReadingSessionService : IReadingSessionService, IDisposable, public async Task UpdateProgress(int userId, ProgressDto progressDto, ClientInfoData? clientInfo, int? deviceId) { - _logger.LogDebug("Updating Reading Session for {UserId} on {ChapterId}", userId, progressDto.ChapterId); + // We need to lock per-user as progress events can come fast and duplicate, as we are using new DataContext per Background Task + var userLock = UserLocks.GetOrAdd(userId, _ => new SemaphoreSlim(1, 1)); - using var scope = _serviceScopeFactory.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - var eventHub = scope.ServiceProvider.GetRequiredService(); + await userLock.WaitAsync(); - var session = await GetOrCreateSessionAsync(userId, progressDto, context); + try + { + _logger.LogDebug("Updating Reading Session for {UserId} on {ChapterId}", userId, progressDto.ChapterId); - await UpdateActivityDataAsync(session, progressDto, clientInfo, deviceId, scope, context); + using var scope = _serviceScopeFactory.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var eventHub = scope.ServiceProvider.GetRequiredService(); - session.LastModified = DateTime.Now; - session.LastModifiedUtc = DateTime.UtcNow; + var session = await GetOrCreateSessionAsync(userId, progressDto, context); - await context.SaveChangesAsync(); + await UpdateActivityDataAsync(session, progressDto, clientInfo, deviceId, scope, context); - await eventHub.SendMessageAsync(MessageFactory.ReadingSessionUpdate, MessageFactory.ReadingSessionUpdateEvent(userId, session.Id)); + session.LastModified = DateTime.Now; + session.LastModifiedUtc = DateTime.UtcNow; + + await context.SaveChangesAsync(); + + await eventHub.SendMessageAsync(MessageFactory.ReadingSessionUpdate, + MessageFactory.ReadingSessionUpdateEvent(userId, session.Id)); + } + finally + { + userLock.Release(); + } } private async Task GetOrCreateSessionAsync(int userId, ProgressDto dto, DataContext context) @@ -94,9 +109,11 @@ public sealed class ReadingSessionService : IReadingSessionService, IDisposable, if (existingSession != null) { + _logger.LogDebug("Found existing session {SessionId} for user {UserId} for Chapter {ChapterId}", existingSession.Id, userId, dto.ChapterId); return existingSession; } + var chapterFormat = await GetChapterFormatAsync(dto.ChapterId, context); var newSession = new AppUserReadingSession { @@ -112,6 +129,7 @@ public sealed class ReadingSessionService : IReadingSessionService, IDisposable, context.AppUserReadingSession.Add(newSession); await context.SaveChangesAsync(); + _logger.LogDebug("Created new session {SessionId} for user {UserId} for Chapter {ChapterId}", newSession.Id, userId, dto.ChapterId); return newSession; } @@ -128,10 +146,12 @@ public sealed class ReadingSessionService : IReadingSessionService, IDisposable, if (existingActivity != null) { + _logger.LogDebug("Updating Session {SessionId} with an existing Activity {ActivityId}", session.Id, existingActivity.Id); await UpdateExistingActivityAsync(existingActivity, progressDto, clientInfo, deviceId, chapterFormat, scope); } else { + _logger.LogDebug("Updating Session {SessionId} with a new Activity", session.Id); var newActivity = NewActivityData(progressDto, chapterFormat); if (clientInfo != null) { @@ -258,6 +278,7 @@ public sealed class ReadingSessionService : IReadingSessionService, IDisposable, foreach (var session in expiredSessions) { + _logger.LogDebug("Closing session {SessionId} for user {UserId}", session.Id, session.AppUserId); var completedIds = CloseSession(session); allCompletedChapterIds.AddRange(completedIds); } @@ -351,25 +372,7 @@ public sealed class ReadingSessionService : IReadingSessionService, IDisposable, { var startPage = format == MangaFormat.Epub ? dto.PageNum : Math.Max(dto.PageNum - 1, 0); - return new AppUserReadingSessionActivityData - { - ChapterId = dto.ChapterId, - VolumeId = dto.VolumeId, - SeriesId = dto.SeriesId, - LibraryId = dto.LibraryId, - StartPage = startPage, - StartBookScrollId = dto.BookScrollId, - EndPage = dto.PageNum, - StartTime = DateTime.Now, - StartTimeUtc = DateTime.UtcNow, - EndTime = null, - EndTimeUtc = null, - PagesRead = 0, - WordsRead = 0, - ClientInfo = null, - DeviceIds = [], - Format = format, - }; + return new AppUserReadingSessionActivityData(dto, startPage, format); } public void Dispose() @@ -378,6 +381,7 @@ public sealed class ReadingSessionService : IReadingSessionService, IDisposable, _cleanupTimer.Dispose(); _cleanupLock.Dispose(); + _disposed = true; } @@ -387,6 +391,7 @@ public sealed class ReadingSessionService : IReadingSessionService, IDisposable, await _cleanupTimer.DisposeAsync(); _cleanupLock.Dispose(); + _disposed = true; } } diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 381654b8a..f0b16c8d1 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -12,7 +12,7 @@ namespace API.Services.Tasks.Scanner.Parser; public static partial class Parser { // NOTE: If you change this, don't forget to change in the UI (see Series Detail) - public const string DefaultChapter = "-100000"; // -2147483648 + public const string DefaultChapter = "-100000"; public const string LooseLeafVolume = "-100000"; public const int DefaultChapterNumber = -100_000; public const int LooseLeafVolumeNumber = -100_000; diff --git a/API/Startup.cs b/API/Startup.cs index 860cd1289..368a86816 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -481,6 +481,7 @@ public class Startup #endregion #region v0.8.9 + await new MigrateBadKoreaderProgress().RunAsync(dataContext, logger); await new MigrateProgressToReadingSessions().RunAsync(dataContext, logger); await new MigrateMissingCreatedUtcDate().RunAsync(dataContext, logger); await new MigrateTotalReads().RunAsync(dataContext, logger); @@ -488,7 +489,6 @@ public class Startup await new MigrateMissingAppUserRatingDateColumns().RunAsync(dataContext, logger); await new MigrateFormatToActivityDataV2().RunAsync(dataContext, logger); await new MigrateIncorrectUtcTimes().RunAsync(dataContext, logger); - await new MigrateBadKoreaderProgress().RunAsync(dataContext, logger); #endregion #endregion diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 9d8eb61f1..51b9f66c2 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -451,6 +451,7 @@ "integrity": "sha512-PYVgNbjNtuD5/QOuS6cHR8A7bRqsVqxtUUXGqdv76FYMAajQcAvyfR0QxOkqf3NmYxgNgO3hlUHWq0ILjVbcow==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@angular-eslint/bundled-angular-compiler": "21.1.0", "eslint-scope": "^9.0.0" @@ -496,6 +497,7 @@ "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.0.7.tgz", "integrity": "sha512-TfGE+emi67LAIUYmyiHfnL8BVqk26ZZVNEz7hDfbFztbZ5qhtHeKoG+97bAKtJDTTkxgs1JvB8escZExe1JkdA==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -611,6 +613,7 @@ "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.0.5.tgz", "integrity": "sha512-yO/IRYEZ5wJkpwg3GT3b6RST4pqNFTAhuyPdEdLcE81cs283K3aKOsCYh2xUR3bR4WxBh2kBPSJ31AFZyJXbSA==", "license": "MIT", + "peer": true, "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" @@ -627,6 +630,7 @@ "integrity": "sha512-UYFQqn9Ow1wFVSwdB/xfjmZo4Yb7CUNxilbeYDFIybesfxXSdjMJBbXLtV0+icIhjmqfSUm2gTls6WIrG8qv9A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@angular-devkit/architect": "0.2100.5", "@angular-devkit/core": "21.0.5", @@ -662,6 +666,7 @@ "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.0.7.tgz", "integrity": "sha512-KNstFFCv6//x33F+YBPEIztDSNBVyLH99C8yFPmb7vawxGbR9liKSHC1WnEk+GR5KgV3I5lFOJyWL7Elfm0K5A==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -678,6 +683,7 @@ "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.0.7.tgz", "integrity": "sha512-Qsjx0OrOquyx10fMynkHilRRoZy9qJcstHdML7NGKg887xqHW4YvgNKREIXmKYjnV6sUBBUxJUD1L5ouarb/YA==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -690,6 +696,7 @@ "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.0.7.tgz", "integrity": "sha512-M4ePAA7AwjTsbUq6Qpremgo7qIP9GIgWqV5FoJPUEthtFGPNEiKGYjpOtXJ/OLB1J2Tn0ygrqe0PAYE0YxeEUA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "7.28.4", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -722,6 +729,7 @@ "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.0.7.tgz", "integrity": "sha512-MvgRRse2PaEleQFp+35rj7ew5gBmBh3wp5yNDYPTiPaVp1I3fJ08VYSpldodaXmdkdWRB+OU4WJhnFkagyRx7A==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -747,6 +755,7 @@ "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.0.7.tgz", "integrity": "sha512-HUfUaO6+cxam9wug3Upc83ueBIDSgJwxzYIuPCP4AjL5DhT6Fbqv/Zq+nLbLF7rklbKdqzYsMjse97pxmxJGLQ==", "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "tslib": "^2.3.0" @@ -766,6 +775,7 @@ "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-21.0.7.tgz", "integrity": "sha512-H1BdOOe0prtQa/EjWyzyZ9Ls4dPHcPNK/oN4fAYkpaZzyyqhvmPU64TYHa/3DNxFQrbSYjVMcpRXIJFThLeOZQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "7.28.4", "@types/babel__core": "7.20.5", @@ -790,6 +800,7 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.0.7.tgz", "integrity": "sha512-mhsN2hn5qG0Oelqpko3uLmYdqadruzG2rY3CJ7duRdOrzs5g5F8QhzphoI/ljgLyxrrgZT6Nykyyf6RNhowf2A==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -871,6 +882,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2162,6 +2174,7 @@ "integrity": "sha512-X7/+dG9SLpSzRkwgG5/xiIzW0oMrV3C0HOa7YHG1WnrLK+vCQHfte4k/T80059YBdei29RBC3s+pSMvPJDU9/A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@inquirer/checkbox": "^4.3.0", "@inquirer/confirm": "^5.1.19", @@ -2402,6 +2415,7 @@ "resolved": "https://registry.npmjs.org/@jsverse/transloco/-/transloco-8.2.0.tgz", "integrity": "sha512-5SU9mjmKHlTraW/GKSUsWEjt7ATBLzKcKd6w+mTbRrnU38ZyYdCJoR2W/ii8lWiRwhfgbXTFCsTUueW5Ak61WA==", "license": "MIT", + "peer": true, "dependencies": { "@jsverse/transloco-utils": "^8.2.0", "@jsverse/utils": "1.0.0-beta.5", @@ -2484,7 +2498,8 @@ "version": "1.0.0-beta.5", "resolved": "https://registry.npmjs.org/@jsverse/utils/-/utils-1.0.0-beta.5.tgz", "integrity": "sha512-z7IdlV6BdSeF3Veii8Yyk64KuyTjNIQnFaW5PAhmDx0wN29lB2BFp8WO6+tJPLPjtlz2yKeNrjkp1XqnMPaeHA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "3.0.5", @@ -3763,6 +3778,7 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -4563,7 +4579,8 @@ "version": "25.0.0", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", @@ -4958,6 +4975,7 @@ "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -5003,6 +5021,7 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -5110,6 +5129,7 @@ "integrity": "sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -5152,6 +5172,7 @@ "integrity": "sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.52.0", @@ -5263,6 +5284,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5577,6 +5599,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6267,29 +6290,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -6452,6 +6452,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6758,6 +6759,7 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -7898,6 +7900,7 @@ "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", @@ -9563,6 +9566,7 @@ "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "eventemitter3": "^5.0.1", "lodash-es": "^4.17.21", @@ -9812,6 +9816,7 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -9829,6 +9834,7 @@ "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -10506,7 +10512,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tuf-js": { "version": "4.1.0", @@ -10557,6 +10564,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10721,6 +10729,7 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -11575,6 +11584,7 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -11593,7 +11603,8 @@ "version": "0.16.0", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.0.tgz", "integrity": "sha512-LqLPpIQANebrlxY6jKcYKdgN5DTXyyHAKnnWWjE5pPfEQ4n7j5zn7mOEEpwNZVKGqx3kKKmvplEmoBrvpgROTA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/zrender": { "version": "6.0.0", diff --git a/UI/Web/src/app/_guards/profile.guard.ts b/UI/Web/src/app/_guards/profile.guard.ts index bdfa95773..1342f602d 100644 --- a/UI/Web/src/app/_guards/profile.guard.ts +++ b/UI/Web/src/app/_guards/profile.guard.ts @@ -1,16 +1,18 @@ -import {CanActivateFn} from '@angular/router'; +import {CanActivateFn, RedirectCommand, Router} from '@angular/router'; import {AccountService} from "../_services/account.service"; import {inject} from "@angular/core"; import {MemberService} from "../_services/member.service"; import {ToastrService} from "ngx-toastr"; import {translate} from "@jsverse/transloco"; import {tap} from "rxjs"; +import {map} from "rxjs/operators"; export const profileGuard: CanActivateFn = (route, state) => { const userId = parseInt(route.params['userId'] || route.parent?.params['userId'], 10); const accountService = inject(AccountService); const memberService = inject(MemberService); + const router = inject(Router); const toastr = inject(ToastrService); // If this is my profile, allow @@ -21,10 +23,17 @@ export const profileGuard: CanActivateFn = (route, state) => { // Otherwise check if that user has their account shared return memberService.hasProfileShared(userId).pipe( tap(hasAccess => { - console.log('hasAccess', hasAccess); if (!hasAccess) { toastr.info(translate('toasts.profile-unauthorized')); } + }), + map(hasAccess => { + if (hasAccess) { + return true; + } + + const dashboard = router.parseUrl('/home'); + return new RedirectCommand(dashboard); }) ); }; diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 766d67988..325083871 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -3024,17 +3024,6 @@ "saturday-short": "Sat" }, - "ordinal-date-pipe": { - "ordinalFormat": "{{month}} {{day}}", - "ordinal": { - "st": "st", - "nd": "nd", - "rd": "rd", - "th": "th" - } - }, - - "errors": { "series-doesnt-exist": "This series no longer exists", "collection-invalid-access": "You don't have access to any libraries this tag belongs to or this collection is invalid",