diff --git a/API.Tests/Services/EntityNamingServiceTests.cs b/API.Tests/Services/EntityNamingServiceTests.cs
index 1d4399da5..b26d366bc 100644
--- a/API.Tests/Services/EntityNamingServiceTests.cs
+++ b/API.Tests/Services/EntityNamingServiceTests.cs
@@ -516,6 +516,155 @@ public class EntityNamingServiceTests
#endregion
+ #region BuildChapterTitle Tests
+
+ [Fact]
+ public void BuildChapterTitle_SingleChapterVolume_ReturnsVolumeOnly()
+ {
+ var chapter = CreateChapterDto(range: "1");
+ var volume = CreateVolumeDto(name: "5", minNumber: 5, chapters: [chapter]);
+
+ var result = _sut.BuildChapterTitle(LibraryType.Manga, volume, chapter);
+
+ Assert.Equal("Volume 5", result);
+ }
+
+ [Fact]
+ public void BuildChapterTitle_MultipleChapterVolume_ReturnsVolumeAndChapter()
+ {
+ var chapter1 = CreateChapterDto(range: "1", title: "The Beginning");
+ var chapter2 = CreateChapterDto(range: "2");
+ var volume = CreateVolumeDto(name: "1", minNumber: 1, chapters: [chapter1, chapter2]);
+
+ var result = _sut.BuildChapterTitle(LibraryType.Manga, volume, chapter1);
+
+ Assert.Equal("Volume 1 - Chapter 1 - The Beginning", result);
+ }
+
+ [Fact]
+ public void BuildChapterTitle_SpecialVolume_ReturnsChapterOnly()
+ {
+ var chapter = CreateChapterDto(range: "SP01", title: "Bonus", isSpecial: true);
+ var volume = CreateVolumeDto(name: "Specials", minNumber: Parser.SpecialVolumeNumber, chapters: [chapter]);
+
+ var result = _sut.BuildChapterTitle(LibraryType.Manga, volume, chapter);
+
+ Assert.NotEmpty(result);
+ Assert.DoesNotContain("Volume", result);
+ }
+
+ [Fact]
+ public void BuildChapterTitle_LooseLeafVolume_SingleChapter_ReturnsEmpty()
+ {
+ var chapter = CreateChapterDto(range: "1");
+ var volume = CreateVolumeDto(name: "0", minNumber: Parser.LooseLeafVolumeNumber, chapters: [chapter]);
+
+ var result = _sut.BuildChapterTitle(LibraryType.Manga, volume, chapter);
+
+ Assert.Equal(string.Empty, result);
+ }
+
+ [Fact]
+ public void BuildChapterTitle_LooseLeafVolume_MultipleChapters_ReturnsChapterOnly()
+ {
+ var chapter1 = CreateChapterDto(range: "1");
+ var chapter2 = CreateChapterDto(range: "2");
+ var volume = CreateVolumeDto(name: "0", minNumber: Parser.LooseLeafVolumeNumber, chapters: [chapter1, chapter2]);
+
+ var result = _sut.BuildChapterTitle(LibraryType.Manga, volume, chapter1);
+
+ Assert.Equal("Chapter 1", result);
+ }
+
+ [Fact]
+ public void BuildChapterTitle_Comic_SingleChapterVolume_ReturnsVolumeOnly()
+ {
+ var chapter = CreateChapterDto(range: "1");
+ var volume = CreateVolumeDto(name: "1", minNumber: 1, chapters: [chapter]);
+
+ var result = _sut.BuildChapterTitle(LibraryType.Comic, volume, chapter);
+
+ Assert.Equal("Volume 1", result);
+ }
+
+ [Fact]
+ public void BuildChapterTitle_Comic_MultipleChapterVolume_UsesIssueFormat()
+ {
+ var chapter1 = CreateChapterDto(range: "1");
+ var chapter2 = CreateChapterDto(range: "2");
+ var volume = CreateVolumeDto(name: "1", minNumber: 1, chapters: [chapter1, chapter2]);
+
+ var result = _sut.BuildChapterTitle(LibraryType.Comic, volume, chapter1);
+
+ Assert.Equal("Volume 1 - Issue #1", result);
+ }
+
+ [Fact]
+ public void BuildChapterTitle_Book_SingleChapterVolume_WithTitleName_ReturnsTitleName()
+ {
+ var chapter = CreateChapterDto(titleName: "The Fellowship of the Ring");
+ var volume = CreateVolumeDto(name: "1", minNumber: 1, chapters: [chapter]);
+
+ var result = _sut.BuildChapterTitle(LibraryType.Book, volume, chapter);
+
+ Assert.Equal("The Fellowship of the Ring", result);
+ }
+
+ [Fact]
+ public void BuildChapterTitle_Book_MultipleChapterVolume_ReturnsVolumeAndBook()
+ {
+ var chapter1 = CreateChapterDto(range: "1", title: "Part One");
+ var chapter2 = CreateChapterDto(range: "2", title: "Part Two");
+ var volume = CreateVolumeDto(name: "1", minNumber: 1, chapters: [chapter1, chapter2]);
+
+ var result = _sut.BuildChapterTitle(LibraryType.Book, volume, chapter1);
+
+ Assert.Contains("Book Part One", result);
+ }
+
+ [Fact]
+ public void BuildChapterTitle_WithCustomLabels_UsesProvidedLabels()
+ {
+ var chapter1 = CreateChapterDto(range: "5");
+ var chapter2 = CreateChapterDto(range: "6");
+ var volume = CreateVolumeDto(name: "2", minNumber: 2, chapters: [chapter1, chapter2]);
+
+ var result = _sut.BuildChapterTitle(
+ LibraryType.Manga, volume, chapter1,
+ volumeLabel: "Band",
+ chapterLabel: "Kapitel");
+
+ Assert.Equal("Band 2 - Kapitel 5", result);
+ }
+
+ [Fact]
+ public void BuildChapterTitle_VolumeAlreadyHasPrefix_DoesNotDuplicate()
+ {
+ var chapter1 = CreateChapterDto(range: "1");
+ var chapter2 = CreateChapterDto(range: "2");
+ var volume = CreateVolumeDto(name: "Volume 1", minNumber: 1, chapters: [chapter1, chapter2]);
+
+ var result = _sut.BuildChapterTitle(LibraryType.Manga, volume, chapter1);
+
+ Assert.Equal("Volume 1 - Chapter 1", result);
+ Assert.DoesNotContain("Volume Volume", result);
+ }
+
+ [Fact]
+ public void BuildChapterTitle_RedundantChapterTitle_DoesNotDuplicate()
+ {
+ var chapter1 = CreateChapterDto(range: "1448", title: "Chapter 1448");
+ var chapter2 = CreateChapterDto(range: "1449");
+ var volume = CreateVolumeDto(name: "100", minNumber: 100, chapters: [chapter1, chapter2]);
+
+ var result = _sut.BuildChapterTitle(LibraryType.Manga, volume, chapter1);
+
+ Assert.Equal("Volume 100 - Chapter 1448", result);
+ }
+
+ #endregion
+
+
#region FormatReadingListItemTitle Tests
diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs
index 8099f70af..8405a0020 100644
--- a/API/Controllers/StatsController.cs
+++ b/API/Controllers/StatsController.cs
@@ -17,6 +17,7 @@ using API.DTOs.Stats.V3.ClientDevice;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
+using API.Helpers;
using API.Middleware;
using API.Services;
using API.Services.Tasks.Scanner.Parser;
@@ -443,6 +444,25 @@ public class StatsController(
return Ok(await statService.GetUserReadStatistics(userId, []));
}
+
+ ///
+ /// Return a user's reading session history
+ ///
+ ///
+ ///
+ ///
+ ///
+ [HttpGet("reading-history")]
+ [ProfilePrivacy]
+ public async Task>> GetReadingHistoryItems([FromQuery] int userId, [FromQuery] StatsFilterDto filter, [FromQuery] UserParams userParams)
+ {
+ var result = await statService.GetReadingHistoryItems(filter, userParams, userId, UserId);
+
+ Response.AddPaginationHeader(result.CurrentPage, result.PageSize, result.TotalCount, result.TotalPages);
+
+ return Ok(result);
+ }
+
// TODO: Can we cache this? Can we make an attribute to cache methods based on keys?
///
/// Cleans the stats filter to only include valid data. I.e. only requests libraries the user has access to
diff --git a/API/DTOs/Statistics/ReadingHistoryItemDto.cs b/API/DTOs/Statistics/ReadingHistoryItemDto.cs
new file mode 100644
index 000000000..fdadc92a9
--- /dev/null
+++ b/API/DTOs/Statistics/ReadingHistoryItemDto.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Collections.Generic;
+using API.Entities.Enums;
+
+namespace API.DTOs.Statistics;
+
+public sealed record ReadingHistoryItemDto
+{
+ public List SessionDataIds { get; set; }
+ public int SessionId { get; set; }
+ public DateTime StartTimeUtc { get; set; }
+ public DateTime EndTimeUtc { get; set; }
+ public DateTime LocalDate { get; set; } // For UI grouping by day
+
+ // Series info
+ public int SeriesId { get; set; }
+ public string SeriesName { get; set; } = string.Empty;
+ public MangaFormat SeriesFormat { get; set; }
+
+ // Chapter info
+ public List Chapters { get; set; }
+
+ // Library info
+ public int LibraryId { get; set; }
+ public string LibraryName { get; set; } = string.Empty;
+
+ // Reading stats for this session
+ public int PagesRead { get; set; }
+ public int WordsRead { get; set; }
+ public int DurationSeconds { get; set; }
+
+ public int TotalPages { get; set; }
+}
+
+public sealed record ReadingHistoryChapterItemDto
+{
+ public int ChapterId { get; set; }
+ public string Label { get; set; }
+ public DateTime StartTimeUtc { get; set; }
+ public DateTime EndTimeUtc { get; set; }
+
+ public int PagesRead { get; set; }
+ public int WordsRead { get; set; }
+ public int DurationSeconds { get; set; }
+
+ public int StartPage { get; set; }
+ public int EndPage { get; set; }
+ public int TotalPages { get; set; }
+ public bool Completed { get; set; }
+}
diff --git a/API/DTOs/Statistics/StatsFilterDto.cs b/API/DTOs/Statistics/StatsFilterDto.cs
index 99767f486..a0c76524b 100644
--- a/API/DTOs/Statistics/StatsFilterDto.cs
+++ b/API/DTOs/Statistics/StatsFilterDto.cs
@@ -2,10 +2,16 @@ using System;
using System.Collections.Generic;
namespace API.DTOs.Statistics;
+#nullable enable
public sealed record StatsFilterDto
{
public DateTime? StartDate { get; set; }
+ ///
+ /// Timezone of the user, will zone to this TimeZone
+ ///
+ /// America/Los_Angeles
+ public string? TimeZoneId { get; set; }
public DateTime? EndDate
{
diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs
index 79bd6bfb4..9f7d560c8 100644
--- a/API/Data/DataContext.cs
+++ b/API/Data/DataContext.cs
@@ -331,6 +331,17 @@ public sealed class DataContext : IdentityDbContext());
+
+ builder.Entity(entity =>
+ {
+ // Covers: active session lookup, all sessions by user, and cleanup query
+ entity.HasIndex(s => new { s.AppUserId, s.IsActive })
+ .HasDatabaseName("IX_AppUserReadingSession_AppUserId_IsActive");
+
+ // Cleanup query: finding expired active sessions
+ entity.HasIndex(s => new { s.IsActive, s.LastModifiedUtc })
+ .HasDatabaseName("IX_AppUserReadingSession_IsActive_LastModifiedUtc");
+ });
#endregion
#region Client Device
diff --git a/API/Data/Migrations/20260109144351_ReadingSessionIndex.Designer.cs b/API/Data/Migrations/20260109144351_ReadingSessionIndex.Designer.cs
new file mode 100644
index 000000000..d38a7c19d
--- /dev/null
+++ b/API/Data/Migrations/20260109144351_ReadingSessionIndex.Designer.cs
@@ -0,0 +1,4482 @@
+//
+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("20260109144351_ReadingSessionIndex")]
+ partial class ReadingSessionIndex
+ {
+ ///
+ 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