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("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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Data") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"TotalMinutesRead\":0,\"TotalPagesRead\":0,\"TotalWordsRead\":0,\"LongestSessionMinutes\":0,\"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("LastAccessedAt") + .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/20260109144351_ReadingSessionIndex.cs b/API/Data/Migrations/20260109144351_ReadingSessionIndex.cs new file mode 100644 index 000000000..e7dcbf04f --- /dev/null +++ b/API/Data/Migrations/20260109144351_ReadingSessionIndex.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class ReadingSessionIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_AppUserReadingSession_AppUserId", + table: "AppUserReadingSession"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserReadingSession_AppUserId_IsActive", + table: "AppUserReadingSession", + columns: new[] { "AppUserId", "IsActive" }); + + migrationBuilder.CreateIndex( + name: "IX_AppUserReadingSession_IsActive_LastModifiedUtc", + table: "AppUserReadingSession", + columns: new[] { "IsActive", "LastModifiedUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_AppUserReadingSession_AppUserId_IsActive", + table: "AppUserReadingSession"); + + migrationBuilder.DropIndex( + name: "IX_AppUserReadingSession_IsActive_LastModifiedUtc", + table: "AppUserReadingSession"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserReadingSession_AppUserId", + table: "AppUserReadingSession", + column: "AppUserId"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index dc35defa3..d569443ac 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -2186,10 +2186,14 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.HasIndex("AppUserId"); - b.HasIndex("IsActive"); + b.HasIndex("AppUserId", "IsActive") + .HasDatabaseName("IX_AppUserReadingSession_AppUserId_IsActive"); + + b.HasIndex("IsActive", "LastModifiedUtc") + .HasDatabaseName("IX_AppUserReadingSession_IsActive_LastModifiedUtc"); + b.ToTable("AppUserReadingSession"); }); diff --git a/API/Helpers/Formatting/LocalizedNamingContext.cs b/API/Helpers/Formatting/LocalizedNamingContext.cs index d6b00b72a..c940bb97b 100644 --- a/API/Helpers/Formatting/LocalizedNamingContext.cs +++ b/API/Helpers/Formatting/LocalizedNamingContext.cs @@ -75,6 +75,12 @@ public sealed class LocalizedNamingContext VolumeLabel, ChapterLabel, IssueLabel, BookLabel); } + public string BuildChapterTitle(VolumeDto volume, ChapterDto chapter) + { + return _namingService.BuildChapterTitle(LibraryType, volume, chapter, + VolumeLabel, ChapterLabel, IssueLabel, BookLabel); + } + /// /// Formats a reading list item title using the pre-fetched localized labels. /// diff --git a/API/Helpers/PagedList.cs b/API/Helpers/PagedList.cs index d5d32af5a..4ab566e5c 100644 --- a/API/Helpers/PagedList.cs +++ b/API/Helpers/PagedList.cs @@ -38,4 +38,9 @@ public class PagedList : List return new PagedList(itemsTask.Result, countTask.Result, pageNumber, pageSize); } + + public static PagedList Create(IEnumerable items, int totalCount, int pageNumber, int pageSize) + { + return new PagedList(items, totalCount, pageNumber, pageSize); + } } diff --git a/API/Services/EntityNamingService.cs b/API/Services/EntityNamingService.cs index 8013af0e6..5cf2021ee 100644 --- a/API/Services/EntityNamingService.cs +++ b/API/Services/EntityNamingService.cs @@ -30,13 +30,17 @@ public interface IEntityNamingService /// Formats a volume name based on library type and volume metadata. /// string? FormatVolumeName(LibraryType libraryType, VolumeDto volume, string? volumeLabel = null); - /// /// Builds a full display title for a chapter within a series/volume context. /// Used for OPDS feeds, reading lists, etc. /// string BuildFullTitle(LibraryType libraryType, SeriesDto series, VolumeDto? volume, ChapterDto chapter, string? volumeLabel = null, string? chapterLabel = null, string? issueLabel = null, string? bookLabel = null); /// + /// Builds a display title for a chapter within its volume context. + /// Used when series context is not needed (e.g., reading history within a series grouping). + /// + string BuildChapterTitle(LibraryType libraryType, VolumeDto volume, ChapterDto chapter, string? volumeLabel = null, string? chapterLabel = null, string? issueLabel = null, string? bookLabel = null); + /// /// Formats a reading list item title based on the item's metadata. /// Handles the unique naming conventions for reading list display. /// @@ -127,31 +131,36 @@ public partial class EntityNamingService : IEntityNamingService return $"{seriesName} - {chapterTitle}"; } + var title = BuildChapterTitle(libraryType, volume, chapter, volumeLabel, chapterLabel, issueLabel, bookLabel); + + return string.IsNullOrEmpty(title) + ? seriesName + : $"{seriesName} - {title}"; + } + + public string BuildChapterTitle(LibraryType libraryType, VolumeDto volume, ChapterDto chapter, string? volumeLabel = null, + string? chapterLabel = null, string? issueLabel = null, string? bookLabel = null) + { + volumeLabel ??= DefaultVolumeLabel; + // Special volume - just use chapter title if (volume.IsSpecial()) { - var chapterTitle = FormatChapterTitle(libraryType, chapter, chapterLabel, issueLabel, bookLabel); - return $"{seriesName} - {chapterTitle}"; + return FormatChapterTitle(libraryType, chapter, chapterLabel, issueLabel, bookLabel); } - // Loose-leaf volume - series name only or with chapter + // Loose-leaf volume if (volume.IsLooseLeaf()) { - if (volume.Chapters.Count == 1) - { - return seriesName; - } - var chapterTitle = FormatChapterTitle(libraryType, chapter, chapterLabel, issueLabel, bookLabel); - return $"{seriesName} - {chapterTitle}"; + return volume.Chapters.Count == 1 + ? string.Empty // Caller may want to handle this (e.g., use series name only) + : FormatChapterTitle(libraryType, chapter, chapterLabel, issueLabel, bookLabel); } // Single chapter in volume - use volume name only if (volume.Chapters.Count == 1) { - var volumeName = FormatVolumeName(libraryType, volume, volumeLabel); - return volumeName != null - ? $"{seriesName} - {volumeName}" - : seriesName; + return FormatVolumeName(libraryType, volume, volumeLabel) ?? string.Empty; } // Multiple chapters in volume - include both volume and chapter @@ -159,7 +168,7 @@ public partial class EntityNamingService : IEntityNamingService ?? FormatStandardVolumeName(volume.Name, volumeLabel); var chapTitle = FormatChapterTitle(libraryType, chapter, chapterLabel, issueLabel, bookLabel); - return $"{seriesName} - {volName} - {chapTitle}"; + return $"{volName} - {chapTitle}"; } public string FormatReadingListItemTitle(ReadingListItemDto item, diff --git a/API/Services/HostedServices/ReadingSessionInitializer.cs b/API/Services/HostedServices/ReadingSessionInitializer.cs index 620ced7ce..bd4a652f3 100644 --- a/API/Services/HostedServices/ReadingSessionInitializer.cs +++ b/API/Services/HostedServices/ReadingSessionInitializer.cs @@ -24,22 +24,22 @@ public class ReadingSessionInitializer : IHostedService public async Task StartAsync(CancellationToken cancellationToken) { - _logger.LogInformation("Ensuring all reading sessions are closed"); + _logger.LogInformation("Closing any orphaned reading sessions from previous run"); using var scope = _serviceScopeFactory.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); - await context.AppUserReadingSession - .Where(s => s.EndTime == null) + var closedCount = await context.AppUserReadingSession + .Where(s => s.IsActive) .ExecuteUpdateAsync(s => s - .SetProperty(x => x.IsActive, false) - .SetProperty(x => x.EndTime, DateTime.Now) - .SetProperty(x => x.EndTimeUtc, DateTime.UtcNow) - .SetProperty(x => x.LastModified, DateTime.Now) - .SetProperty(x => x.LastModifiedUtc, DateTime.UtcNow), + .SetProperty(x => x.IsActive, false) + .SetProperty(x => x.EndTime, x => x.LastModified) + .SetProperty(x => x.EndTimeUtc, x => x.LastModifiedUtc) + .SetProperty(x => x.LastModified, DateTime.Now) + .SetProperty(x => x.LastModifiedUtc, DateTime.UtcNow), cancellationToken); - _logger.LogInformation("Partial reading sessions cleared"); + _logger.LogInformation("Closed {Count} orphaned reading sessions", closedCount); } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/API/Services/Reading/ReadingSessionService.cs b/API/Services/Reading/ReadingSessionService.cs index 9bbcb5a40..a36cf4b4e 100644 --- a/API/Services/Reading/ReadingSessionService.cs +++ b/API/Services/Reading/ReadingSessionService.cs @@ -1,13 +1,13 @@ using System; -using System.Collections.Concurrent; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using API.Data; using API.DTOs.Progress; +using API.Entities; using API.Entities.Enums; using API.Entities.Progress; -using API.Extensions; using API.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Hybrid; @@ -15,6 +15,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace API.Services.Reading; + #nullable enable public interface IReadingSessionService @@ -22,26 +23,15 @@ public interface IReadingSessionService Task UpdateProgress(int userId, ProgressDto progressDto, ClientInfoData? clientInfo, int? deviceId); } -internal sealed record SessionTimeout -{ - public required T Value { get; set; } - /// - /// Expiration time in Utc - /// - public DateTime ExpirationUtc { get; set; } - public DateTime LastTimerRefresh { get; set; } - public Timer? TimeoutTimer { get; set; } -} - public sealed class ReadingSessionService : IReadingSessionService, IDisposable, IAsyncDisposable { private readonly IServiceScopeFactory _serviceScopeFactory; private readonly ILogger _logger; private readonly HybridCache _cache; - private readonly ConcurrentDictionary> _activeSessions = new(); - private readonly int _defaultTimeoutMinutes; - private readonly int _timerRefreshDebounceSeconds; - private Timer? _midnightRolloverTimer; + private readonly TimeSpan _sessionTimeout; + private readonly TimeSpan _pollInterval; + private readonly Timer _cleanupTimer; + private readonly SemaphoreSlim _cleanupLock = new(1, 1); private bool _disposed; private static readonly HybridCacheEntryOptions ChapterFormatCacheOptions = new() @@ -50,129 +40,278 @@ public sealed class ReadingSessionService : IReadingSessionService, IDisposable, LocalCacheExpiration = TimeSpan.FromMinutes(30) }; - public ReadingSessionService(IServiceScopeFactory serviceScopeFactory, ILogger logger, HybridCache cache, - int defaultTimeoutMinutes = 30, int timerRefreshDebounceSeconds = 5) + public ReadingSessionService( + IServiceScopeFactory serviceScopeFactory, + ILogger logger, + HybridCache cache, + TimeSpan? sessionTimeout = null, + TimeSpan? pollInterval = null) { _serviceScopeFactory = serviceScopeFactory; _logger = logger; _cache = cache; + _sessionTimeout = sessionTimeout ?? TimeSpan.FromMinutes(30); + _pollInterval = pollInterval ?? TimeSpan.FromMinutes(5); - _defaultTimeoutMinutes = defaultTimeoutMinutes; - _timerRefreshDebounceSeconds = timerRefreshDebounceSeconds; - - ScheduleMidnightRollover(); + _cleanupTimer = new Timer( + callback: _ => _ = RunCleanupAsync(), + state: null, + dueTime: _pollInterval, + period: _pollInterval + ); } - public async Task UpdateProgress(int userId, ProgressDto progressDto, ClientInfoData? clientInfo, int? deviceId) { - _logger.LogDebug("Creating/Updating Reading Session for {UserId} on {ChapterId}", userId, progressDto.ChapterId); - - var session = await GetOrCreateSession(userId, progressDto); + _logger.LogDebug("Updating Reading Session for {UserId} on {ChapterId}", userId, progressDto.ChapterId); using var scope = _serviceScopeFactory.CreateScope(); - - // Update session activity data in DB var context = scope.ServiceProvider.GetRequiredService(); - // If Chapter doesn't exist already, add - var existingChapterActivity = session.ActivityData.FirstOrDefault(d => d.ChapterId == progressDto.ChapterId); + var session = await GetOrCreateSessionAsync(userId, progressDto, context); + + await UpdateActivityDataAsync(session, progressDto, clientInfo, deviceId, scope, context); + + session.LastModified = DateTime.Now; + session.LastModifiedUtc = DateTime.UtcNow; + + await context.SaveChangesAsync(); + } + + private async Task GetOrCreateSessionAsync( int userId, ProgressDto dto, DataContext context) + { + var existingSession = await context.AppUserReadingSession + .Where(s => s.IsActive && s.AppUserId == userId) + .Include(s => s.ActivityData) + .FirstOrDefaultAsync(); + + if (existingSession != null) + { + return existingSession; + } + + var chapterFormat = await GetChapterFormatAsync(dto.ChapterId, context); + var newSession = new AppUserReadingSession + { + AppUserId = userId, + StartTime = DateTime.Now, + StartTimeUtc = DateTime.UtcNow, + LastModified = DateTime.Now, + LastModifiedUtc = DateTime.UtcNow, + IsActive = true, + ActivityData = [NewActivityData(dto, chapterFormat)] + }; + + context.AppUserReadingSession.Add(newSession); + await context.SaveChangesAsync(); + + return newSession; + } + + private async Task UpdateActivityDataAsync( AppUserReadingSession session, ProgressDto progressDto, ClientInfoData? clientInfo, + int? deviceId, IServiceScope scope, DataContext context) + { + var existingActivity = session.ActivityData + .FirstOrDefault(d => d.ChapterId == progressDto.ChapterId); - // Use cached chapter format to avoid repeated DB queries var chapterFormat = await GetChapterFormatAsync(progressDto.ChapterId, context); - if (existingChapterActivity != null) + if (existingActivity != null) { - existingChapterActivity.PagesRead = progressDto.PageNum - existingChapterActivity.StartPage; - existingChapterActivity.EndPage = progressDto.PageNum; - existingChapterActivity.EndTime = DateTime.Now; - existingChapterActivity.EndTimeUtc = DateTime.UtcNow; - if (deviceId.HasValue) - { - existingChapterActivity.DeviceIds.Add(deviceId.Value); - } - - existingChapterActivity.DeviceIds = existingChapterActivity.DeviceIds.Distinct().ToList(); - - - // Update client info if it changed (e.g., user switched devices) - if (clientInfo != null) - { - existingChapterActivity.ClientInfo = clientInfo; - } - - - var cacheService = scope.ServiceProvider.GetRequiredService(); - var chapter = await cacheService.Ensure(progressDto.ChapterId); - - - // Store total pages/words in case it changes in the future - existingChapterActivity.TotalPages = chapter?.Pages ?? 0; - existingChapterActivity.TotalWords = chapter?.WordCount ?? 0; - - - if (chapterFormat == MangaFormat.Epub && !string.IsNullOrEmpty(progressDto.BookScrollId)) - { - var bookService = scope.ServiceProvider.GetRequiredService(); - - var cachedFilePath = cacheService.GetCachedFile(chapter!); - - // First update - capture starting position - if (string.IsNullOrEmpty(existingChapterActivity.StartBookScrollId)) - { - existingChapterActivity.StartBookScrollId = progressDto.BookScrollId; - existingChapterActivity.WordsRead = 0; - } - else - { - // Calculate total words read from start to current position - try - { - existingChapterActivity.WordsRead = await bookService.GetWordCountBetweenXPaths( - cachedFilePath, - existingChapterActivity.StartBookScrollId, - existingChapterActivity.StartPage, - progressDto.BookScrollId, - progressDto.PageNum - ); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an error calculating words read for reading session {SessionId} on book {File}", session.Id, cachedFilePath); - } - } - - // Always update the current end position - existingChapterActivity.EndBookScrollId = progressDto.BookScrollId; - } + await UpdateExistingActivityAsync( + existingActivity, progressDto, clientInfo, deviceId, chapterFormat, scope); } else { - // Add new ActivityData for a different chapter in the same session var newActivity = NewActivityData(progressDto, chapterFormat); if (clientInfo != null) { newActivity.ClientInfo = clientInfo; - newActivity.DeviceIds.Add(deviceId!.Value); - - newActivity.DeviceIds = newActivity.DeviceIds.Distinct().ToList(); + } + if (deviceId.HasValue) + { + newActivity.DeviceIds.Add(deviceId.Value); } session.ActivityData.Add(newActivity); } + } - // Update session timestamps + private async Task UpdateExistingActivityAsync( AppUserReadingSessionActivityData activity, ProgressDto progressDto, ClientInfoData? clientInfo, + int? deviceId, MangaFormat chapterFormat, IServiceScope scope) + { + activity.PagesRead = progressDto.PageNum - activity.StartPage; + activity.EndPage = progressDto.PageNum; + activity.EndTime = DateTime.Now; + activity.EndTimeUtc = DateTime.UtcNow; + + if (deviceId.HasValue && !activity.DeviceIds.Contains(deviceId.Value)) + { + activity.DeviceIds.Add(deviceId.Value); + } + + if (clientInfo != null) + { + activity.ClientInfo = clientInfo; + } + + var cacheService = scope.ServiceProvider.GetRequiredService(); + var chapter = await cacheService.Ensure(progressDto.ChapterId); + + activity.TotalPages = chapter?.Pages ?? 0; + activity.TotalWords = chapter?.WordCount ?? 0; + + if (chapterFormat == MangaFormat.Epub && chapter != null && !string.IsNullOrEmpty(progressDto.BookScrollId)) + { + await UpdateEpubActivityAsync(activity, progressDto, chapter, cacheService, scope); + } + } + + private async Task UpdateEpubActivityAsync( AppUserReadingSessionActivityData activity, ProgressDto progressDto, Chapter chapter, + ICacheService cacheService, IServiceScope scope) + { + var bookService = scope.ServiceProvider.GetRequiredService(); + var cachedFilePath = cacheService.GetCachedFile(chapter); + + if (string.IsNullOrEmpty(activity.StartBookScrollId)) + { + activity.StartBookScrollId = progressDto.BookScrollId; + activity.WordsRead = 0; + } + else + { + try + { + activity.WordsRead = await bookService.GetWordCountBetweenXPaths( + cachedFilePath, + activity.StartBookScrollId, + activity.StartPage, + progressDto.BookScrollId!, + progressDto.PageNum + ); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error calculating words read for activity on chapter {ChapterId}", + activity.ChapterId); + } + } + + activity.EndBookScrollId = progressDto.BookScrollId; + } + + private async Task RunCleanupAsync() + { + if (!await _cleanupLock.WaitAsync(TimeSpan.Zero)) + { + _logger.LogDebug("Cleanup already in progress, skipping"); + return; + } + + try + { + await CleanupExpiredSessionsAsync(); + } + finally + { + _cleanupLock.Release(); + } + } + + private async Task CleanupExpiredSessionsAsync() + { + try + { + var cutoffUtc = DateTime.UtcNow - _sessionTimeout; + var midnightToday = DateTime.Today; + + using var scope = _serviceScopeFactory.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var eventHub = scope.ServiceProvider.GetRequiredService(); + + var expiredSessions = await context.AppUserReadingSession + .Where(s => s.IsActive) + .Where(s => s.LastModifiedUtc < cutoffUtc || s.StartTime < midnightToday) + .Include(s => s.ActivityData) + .ToListAsync(); + + if (expiredSessions.Count == 0) return; + + _logger.LogInformation("Closing {Count} expired reading sessions", expiredSessions.Count); + + var allCompletedChapterIds = new List(); + + foreach (var session in expiredSessions) + { + var completedIds = await CloseSessionAsync(session, eventHub); + allCompletedChapterIds.AddRange(completedIds); + } + + await context.SaveChangesAsync(); + + // Batch update total reads + if (allCompletedChapterIds.Count > 0) + { + var distinctChapterIds = allCompletedChapterIds.Distinct().ToList(); + await context.AppUserProgresses + .Where(p => distinctChapterIds.Contains(p.ChapterId)) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.TotalReads, x => x.TotalReads + 1)); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during session cleanup"); + } + } + + private async Task> CloseSessionAsync( + AppUserReadingSession session, + IEventHub eventHub) + { + var lastActivity = session.ActivityData + .Where(ad => ad.EndTime.HasValue) + .MaxBy(ad => ad.EndTime); + + var endTime = lastActivity?.EndTime ?? session.LastModified; + var endTimeUtc = lastActivity?.EndTimeUtc ?? session.LastModifiedUtc; + + // Handle midnight rollover + if (session.StartTime.Date < DateTime.Today) + { + var endOfStartDay = session.StartTime.Date.AddDays(1).AddTicks(-1); + endTime = endOfStartDay; + endTimeUtc = TimeZoneInfo.ConvertTimeToUtc(endOfStartDay); + } + + session.IsActive = false; + session.EndTime = endTime; + session.EndTimeUtc = endTimeUtc; session.LastModified = DateTime.Now; session.LastModifiedUtc = DateTime.UtcNow; - // Save changes - context.AppUserReadingSession.Update(session); - await context.SaveChangesAsync(); + // Collect completed chapters + var completedChapterIds = session.ActivityData + .Where(d => d.TotalPages > 0 && d.EndPage >= d.TotalPages) + .Select(d => d.ChapterId) + .ToList(); + // Clear format caches + foreach (var activity in session.ActivityData) + { + await _cache.RemoveAsync(GetChapterFormatCacheKey(activity.ChapterId)); + } - // Refresh timeout - var cacheKey = GenerateCacheKey(userId, progressDto.ChapterId); - RefreshSessionTimeout(cacheKey, session.Id); + // Notify clients + await eventHub.SendMessageAsync( + MessageFactory.SessionClose, + MessageFactory.SessionCloseEvent(session.Id)); + _logger.LogDebug( + "Closed session {SessionId} for user {UserId}, {ActivityCount} activities, {CompletedCount} completed chapters", + session.Id, session.AppUserId, session.ActivityData.Count, completedChapterIds.Count); + + return completedChapterIds; } private async Task GetChapterFormatAsync(int chapterId, DataContext context) @@ -182,7 +321,7 @@ public sealed class ReadingSessionService : IReadingSessionService, IDisposable, return await _cache.GetOrCreateAsync( cacheKey, (chapterId, context), - async (state, cancel) => + static async (state, cancel) => await state.context.MangaFile .Where(f => f.ChapterId == state.chapterId) .Select(f => f.Format) @@ -190,117 +329,25 @@ public sealed class ReadingSessionService : IReadingSessionService, IDisposable, ChapterFormatCacheOptions); } - private async Task ClearChapterFormatCache(int chapterId) - { - var cacheKey = GetChapterFormatCacheKey(chapterId); - await _cache.RemoveAsync(cacheKey); - } - private static string GetChapterFormatCacheKey(int chapterId) - { - return $"readingsession_chapter_format_{chapterId}"; - } - - private async Task ClearSessionChapterCaches(int sessionId) - { - try - { - using var scope = _serviceScopeFactory.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - - var chapterIds = await context.AppUserReadingSession - .Where(s => s.Id == sessionId) - .SelectMany(s => s.ActivityData) - .Select(ad => ad.ChapterId) - .Distinct() - .ToListAsync(); - - foreach (var chapterId in chapterIds) - { - await ClearChapterFormatCache(chapterId); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to clear chapter format caches for session {SessionId}", sessionId); - } - } - - private async Task GetOrCreateSession(int userId, ProgressDto dto) - { - using var scope = _serviceScopeFactory.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - - // Check if we have an existing cached reading session that is active - var cacheKey = GenerateCacheKey(userId, dto.ChapterId); - if (_activeSessions.TryGetValue(cacheKey, out var sessionTimeout)) - { - if (sessionTimeout.ExpirationUtc <= DateTime.UtcNow) - { - // Expired - close it and create new one - await CloseSession(cacheKey, sessionTimeout.Value); - } - else - { - var session = await context.AppUserReadingSession - .Where(s => s.Id == sessionTimeout.Value) - .Include(s => s.ActivityData) - .FirstOrDefaultAsync(); - - if (session != null) return session; - } - } - - // Look up in the DB for an active reading session - var dbSession = await context.AppUserReadingSession - .Where(s => s.IsActive && s.AppUserId == userId) - .Include(s => s.ActivityData) - .FirstOrDefaultAsync(); - - if (dbSession != null) - { - // Re-add to cache with timer - RefreshSessionTimeout(cacheKey, dbSession.Id); - return dbSession; - } - - var chapterFormat = await GetChapterFormatAsync(dto.ChapterId, context); - - // Create a new session and return it - var newSession = new AppUserReadingSession() - { - AppUserId = userId, - StartTime = DateTime.Now, - StartTimeUtc = DateTime.UtcNow, - IsActive = true, - ActivityData = - [ - NewActivityData(dto, chapterFormat), - ] - }; - - await context.AppUserReadingSession.AddAsync(newSession); - await context.SaveChangesAsync(); - - RefreshSessionTimeout(cacheKey, newSession.Id); - - return newSession; - } + => $"readingsession_chapter_format_{chapterId}"; private static AppUserReadingSessionActivityData NewActivityData(ProgressDto dto, MangaFormat format) { - var page = format == MangaFormat.Epub ? dto.PageNum : Math.Max(dto.PageNum - 1, 0); + 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 = page, + StartPage = startPage, EndPage = dto.PageNum, StartTime = DateTime.Now, StartTimeUtc = DateTime.UtcNow, EndTime = null, + EndTimeUtc = null, PagesRead = 0, WordsRead = 0, ClientInfo = null, @@ -309,274 +356,21 @@ public sealed class ReadingSessionService : IReadingSessionService, IDisposable, }; } - - private void RefreshSessionTimeout(string cacheKey, int sessionId) - { - var now = DateTime.Now; - - _activeSessions.AddOrUpdate(cacheKey, - // Add new - key => new SessionTimeout() - { - Value = sessionId, - ExpirationUtc = now.AddMinutes(_defaultTimeoutMinutes), - LastTimerRefresh = now, - TimeoutTimer = CreateSessionTimer(key, sessionId) - }, - // Update Existing - (_, existing) => - { - // Always update expiration - existing.ExpirationUtc = now.AddMinutes(_defaultTimeoutMinutes); - - // Debounce timer refresh (avoid excessive timer churn) - var secondsSinceLastRefresh = (now - existing.LastTimerRefresh).TotalSeconds; - if (secondsSinceLastRefresh >= _timerRefreshDebounceSeconds) - { - existing.TimeoutTimer?.Change(TimeSpan.FromMinutes(_defaultTimeoutMinutes), TimeSpan.Zero); - - existing.LastTimerRefresh = now; - } - - return existing; - } - ); - } - - private Timer CreateSessionTimer(string cacheKey, int sessionId) - { - return new Timer( - callback: _ => OnSessionTimeout(cacheKey, sessionId), - state: null, - dueTime: TimeSpan.FromMinutes(_defaultTimeoutMinutes), - period: TimeSpan.Zero - ); - } - - private void OnSessionTimeout(string cacheKey, int sessionId) - { - _ = Task.Run(async () => - { - await CloseSession(cacheKey, sessionId); - await ClearSessionChapterCaches(sessionId); - }) - .ContinueWith(t => - { - if (t.IsFaulted) - { - _logger.LogError(t.Exception, "There was an issue closing session {SessionId} with CacheKey: {CacheKey}", - sessionId, cacheKey); - } - }); - } - - private async Task CloseSession(string cacheKey, int sessionId) - { - // Remove from cache and dispose timer - if (_activeSessions.TryRemove(cacheKey, out var session) && session.TimeoutTimer != null) - { - await session.TimeoutTimer.DisposeAsync(); - } - - using var scope = _serviceScopeFactory.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - var eventHub = scope.ServiceProvider.GetRequiredService(); - - // Get the actual last activity end time from ActivityData - var lastActivityTime = await context.AppUserReadingSessionActivityData - .Where(ad => ad.AppUserReadingSessionId == sessionId && ad.EndTime.HasValue) - .MaxAsync(ad => (DateTime?)ad.EndTime); - - var lastActivityTimeUtc = await context.AppUserReadingSessionActivityData - .Where(ad => ad.AppUserReadingSessionId == sessionId && ad.EndTimeUtc.HasValue) - .MaxAsync(ad => (DateTime?)ad.EndTimeUtc); - - if (lastActivityTime == null) return; - - // Use the session's LastModified as the EndTime (the actual last activity) and mark session as inactive - await context.AppUserReadingSession - .Where(s => s.Id == sessionId) - .ExecuteUpdateAsync(s => s - .SetProperty(x => x.IsActive, false) - .SetProperty(x => x.EndTime, lastActivityTime) - .SetProperty(x => x.EndTimeUtc, lastActivityTimeUtc) - .SetProperty(x => x.LastModified, DateTime.Now) - .SetProperty(x => x.LastModifiedUtc, DateTime.UtcNow)); - - await UpdateTotalReadsOnSessionClose(sessionId); - - // Trigger a SessionClose Event so Activity Feed can update - await eventHub.SendMessageAsync(MessageFactory.SessionClose, MessageFactory.SessionCloseEvent(sessionId)); - } - - private async Task UpdateTotalReadsOnSessionClose(int sessionId) - { - using var scope = _serviceScopeFactory.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - - // Check if the user fully read any chapter and increment totalReads for said chapter - var sessionEntry = await context.AppUserReadingSession - .Where(s => s.Id == sessionId) - .Include(s => s.ActivityData) - .FirstAsync(); - - var chapterIds = sessionEntry.ActivityData - .Where(d => d.EndPage >= d.TotalPages) - .Select(d => d.ChapterId) - .ToList(); - - if (chapterIds.Count > 0) - { - await context.AppUserProgresses - .Where(p => chapterIds.Contains(p.ChapterId)) - .ExecuteUpdateAsync(setters => setters - .SetProperty(x => x.TotalReads, x => x.TotalReads + 1)); - } - } - - private void ScheduleMidnightRollover() - { - var now = DateTime.Now; - var nextMidnight = now.Date.AddDays(1); - var timeUntilMidnight = nextMidnight - now; - - _midnightRolloverTimer = new Timer( - callback: _ => - { - // Synchronous callback that starts async work - OnMidnightRolloverAsync().ContinueWith(t => - { - if (t.IsFaulted) - { - _logger.LogCritical("There was an issue closing midnight sessions"); - } - }); - }, - state: null, - dueTime: timeUntilMidnight, - period: TimeSpan.Zero - ); - } - - private async Task OnMidnightRolloverAsync() - { - var endOfYesterday = DateTime.Now.Date.AddTicks(-1); // 23:59:59.9999999 - var endOfYesterdayUtc = TimeZoneInfo.ConvertTimeToUtc(endOfYesterday); - var sessionsToClose = _activeSessions.ToArray(); - - if (sessionsToClose.Length > 0) - { - using var scope = _serviceScopeFactory.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - var eventHub = scope.ServiceProvider.GetRequiredService(); - - var sessionIds = sessionsToClose.Select(kvp => kvp.Value.Value).ToList(); - - // Batch close all sessions in DB - await context.AppUserReadingSession - .Where(s => sessionIds.Contains(s.Id)) - .ExecuteUpdateAsync(s => s - .SetProperty(x => x.IsActive, false) - .SetProperty(x => x.EndTime, endOfYesterday) - .SetProperty(x => x.EndTimeUtc, endOfYesterdayUtc) - .SetProperty(x => x.LastModified, DateTime.Now) - .SetProperty(x => x.LastModifiedUtc, DateTime.UtcNow)); - - // Ensure we increment total reads for any closed sessions - var chapterIds = await context.AppUserReadingSession - .Where(s => sessionIds.Contains(s.Id)) - .Include(s => s.ActivityData) - .SelectMany(s => s.ActivityData - .Where(d => d.EndPage >= d.TotalPages) - .Select(d => d.ChapterId)) - .Distinct() - .ToListAsync(); - - if (chapterIds.Count > 0) - { - await context.AppUserProgresses - .Where(p => chapterIds.Contains(p.ChapterId)) - .ExecuteUpdateAsync(setters => setters - .SetProperty(x => x.TotalReads, x => x.TotalReads + 1)); - } - - foreach (var sessionId in sessionIds) - { - await ClearSessionChapterCaches(sessionId); - - // Trigger a SessionClose Event so Activity Feed can update - await eventHub.SendMessageAsync(MessageFactory.SessionClose, MessageFactory.SessionCloseEvent(sessionId)); - } - - // Clear cache and dispose all timers - foreach (var kvp in sessionsToClose) - { - if (kvp.Value.TimeoutTimer != null) await kvp.Value.TimeoutTimer.DisposeAsync(); - _activeSessions.TryRemove(kvp.Key, out _); - } - } - - // Schedule next midnight Rollover - ScheduleMidnightRollover(); - } - public void Dispose() { - Dispose(disposing: true); - GC.SuppressFinalize(this); + if (_disposed) return; + + _cleanupTimer.Dispose(); + _cleanupLock.Dispose(); + _disposed = true; } public async ValueTask DisposeAsync() - { - await DisposeAsyncCore().ConfigureAwait(false); - - Dispose(disposing: false); - GC.SuppressFinalize(this); - } - - private void Dispose(bool disposing) { if (_disposed) return; - if (disposing) - { - foreach (var session in _activeSessions.Values) - { - session.TimeoutTimer?.Dispose(); - } - - _midnightRolloverTimer?.Dispose(); - _activeSessions.Clear(); - } - + await _cleanupTimer.DisposeAsync(); + _cleanupLock.Dispose(); _disposed = true; } - - private async ValueTask DisposeAsyncCore() - { - if (_disposed) return; - - // Dispose managed resources asynchronously - foreach (var session in _activeSessions.Values) - { - if (session.TimeoutTimer != null) - { - await session.TimeoutTimer.DisposeAsync().ConfigureAwait(false); - } - } - - if (_midnightRolloverTimer != null) - { - await _midnightRolloverTimer.DisposeAsync().ConfigureAwait(false); - } - - _activeSessions.Clear(); - - _disposed = true; - } - - private static string GenerateCacheKey(int userId, int chapterId) - { - return $"{userId}_{chapterId}"; - } } diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 3d5ab8a8a..2324a8dca 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Threading.Tasks; using API.Comparators; @@ -37,15 +36,6 @@ public interface ISeriesService Task DeleteMultipleSeries(IList seriesIds); Task UpdateRelatedSeries(UpdateRelatedSeriesDto dto); Task GetRelatedSeries(int userId, int seriesId); - [Obsolete("Use LocalizedNamingContext")] - Task FormatChapterTitle(int userId, ChapterDto chapter, LibraryType libraryType, bool withHash = true); - [Obsolete("Use LocalizedNamingContext")] - Task FormatChapterTitle(int userId, Chapter chapter, LibraryType libraryType, bool withHash = true); - [Obsolete("Use LocalizedNamingContext")] - Task FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string chapterRange, string? chapterTitle, - bool withHash); - [Obsolete("Use LocalizedNamingContext")] - Task FormatChapterName(int userId, LibraryType libraryType, bool withHash = false); Task GetEstimatedChapterCreationDate(int seriesId, int userId); Task> GetCurrentlyReading(int userId, int requestingUserId, UserParams userParams); Task> GetProfilePrivacyStatements(int userId, int requestingUserId); @@ -538,7 +528,7 @@ public class SeriesService : ISeriesService var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId); - var namingContext = await LocalizedNamingContext.CreateAsync( _namingService, _localizationService, userId, libraryType); + var namingContext = await LocalizedNamingContext.CreateAsync(_namingService, _localizationService, userId, libraryType); var bookTreatment = libraryType is LibraryType.Book or LibraryType.LightNovel; // For books, the Name of the Volume is remapped to the actual name of the book, rather than Volume number. @@ -620,62 +610,6 @@ public class SeriesService : ISeriesService return !chapter.IsSpecial && chapter.MinNumber.IsNot(Parser.DefaultChapterNumber); } - - public async Task FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string chapterRange, string? chapterTitle, bool withHash) - { - if (string.IsNullOrEmpty(chapterTitle) && (isSpecial || libraryType == LibraryType.Book)) throw new ArgumentException("Chapter Title cannot be null"); - - if (isSpecial) - { - return Parser.CleanSpecialTitle(chapterTitle!); - } - - var hashSpot = withHash ? "#" : string.Empty; - var baseChapter = libraryType switch - { - LibraryType.Book => await _localizationService.Translate(userId, "book-num", chapterTitle!), - LibraryType.LightNovel => await _localizationService.Translate(userId, "book-num", chapterRange), - LibraryType.Comic => await _localizationService.Translate(userId, "issue-num", hashSpot, chapterRange), - LibraryType.ComicVine => await _localizationService.Translate(userId, "issue-num", hashSpot, chapterRange), - LibraryType.Manga => await _localizationService.Translate(userId, "chapter-num", chapterRange), - LibraryType.Image => await _localizationService.Translate(userId, "chapter-num", chapterRange), - _ => await _localizationService.Translate(userId, "chapter-num", ' ') - }; - - if (!string.IsNullOrEmpty(chapterTitle) && libraryType != LibraryType.Book && chapterTitle != chapterRange) - { - baseChapter += " - " + chapterTitle; - } - - - return baseChapter; - } - - public async Task FormatChapterTitle(int userId, ChapterDto chapter, LibraryType libraryType, bool withHash = true) - { - return await FormatChapterTitle(userId, chapter.IsSpecial, libraryType, chapter.Range, chapter.Title, withHash); - } - - public async Task FormatChapterTitle(int userId, Chapter chapter, LibraryType libraryType, bool withHash = true) - { - return await FormatChapterTitle(userId, chapter.IsSpecial, libraryType, chapter.Range, chapter.Title, withHash); - } - - // TODO: Refactor this out and use FormatChapterTitle instead across library - public async Task FormatChapterName(int userId, LibraryType libraryType, bool withHash = false) - { - var hashSpot = withHash ? "#" : string.Empty; - return (libraryType switch - { - LibraryType.Book => await _localizationService.Translate(userId, "book-num", string.Empty), - LibraryType.LightNovel => await _localizationService.Translate(userId, "book-num", string.Empty), - LibraryType.Comic => await _localizationService.Translate(userId, "issue-num", hashSpot, string.Empty), - LibraryType.ComicVine => await _localizationService.Translate(userId, "issue-num", hashSpot, string.Empty), - LibraryType.Manga => await _localizationService.Translate(userId, "chapter-num", string.Empty), - _ => await _localizationService.Translate(userId, "chapter-num", ' ') - }).Trim(); - } - /// /// Returns all related series against the passed series Id /// diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index 84b136cad..760041f57 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -17,6 +17,8 @@ using API.Entities.Enums.UserPreferences; using API.Extensions; using API.Extensions.QueryExtensions; using API.Extensions.QueryExtensions.Filtering; +using API.Helpers; +using API.Helpers.Formatting; using API.Services.Tasks.Scanner.Parser; using AutoMapper; using AutoMapper.QueryableExtensions; @@ -68,13 +70,16 @@ public interface IStatisticService Task GetUserStatBar(StatsFilterDto filter, int userId, int requestingUserId); Task> GetMostActiveUsers(StatsFilterDto filter); Task>> GetFilesAddedOverTime(); + Task> GetReadingHistoryItems(StatsFilterDto filter, UserParams userParams, int userId, int requestingUserId); } /// /// Responsible for computing statistics for the server /// /// This performs raw queries and does not use a repository -public class StatisticService(ILogger logger, DataContext context, IMapper mapper, IUnitOfWork unitOfWork): IStatisticService +public class StatisticService(ILogger logger, DataContext context, + IMapper mapper, IUnitOfWork unitOfWork, IEntityNamingService namingService, ILocalizationService localizationService + ): IStatisticService { public async Task GetUserReadStatistics(int userId, IList libraryIds) @@ -633,42 +638,69 @@ public class StatisticService(ILogger logger, DataContext cont public async Task>> ReadCounts(StatsFilterDto filter, int userId = 0) { + var userTimeZone = GetTimeZoneOrUtc(filter.TimeZoneId); var startDate = filter.StartDate?.ToUniversalTime() ?? DateTime.MinValue; var endDate = filter.EndDate?.ToUniversalTime() ?? DateTime.UtcNow; - var results = await context.AppUserReadingSessionActivityData + var rawData = await context.AppUserReadingSessionActivityData .AsNoTracking() .Where(a => a.StartTimeUtc >= startDate && a.StartTimeUtc <= endDate) + .Where(a => a.EndTimeUtc != null) .WhereIf(userId > 0, a => a.ReadingSession.AppUserId == userId) .WhereIf(filter.Libraries is { Count: > 0 }, a => filter.Libraries.Contains(a.LibraryId)) - .GroupBy(a => new { Day = a.StartTimeUtc.Date, a.Format }) + .Select(a => new + { + a.StartTimeUtc, + EndTimeUtc = a.EndTimeUtc!.Value, + a.Format + }) + .ToListAsync(); + + var results = rawData + .GroupBy(a => new + { + Day = TimeZoneInfo.ConvertTimeFromUtc(a.StartTimeUtc, userTimeZone).Date, + a.Format + }) .Select(g => new StatCountWithFormat { Value = g.Key.Day, Format = g.Key.Format, - Count = (long)g.Sum(a => - (double)(a.EndTimeUtc!.Value.Ticks - a.StartTimeUtc.Ticks) / TimeSpan.TicksPerMinute) + Count = (long)g.Sum(a => (a.EndTimeUtc - a.StartTimeUtc).TotalMinutes) }) .OrderBy(d => d.Value) - .ToListAsync(); + .ToList(); - FillMissingDaysAndFormats(results, startDate, endDate); + // Convert boundaries to local for filling + var localStartDate = TimeZoneInfo.ConvertTimeFromUtc( + startDate == DateTime.MinValue ? DateTime.UtcNow.AddYears(-1) : startDate, + userTimeZone); + var localEndDate = TimeZoneInfo.ConvertTimeFromUtc(endDate, userTimeZone); + + FillMissingDaysAndFormats(results, localStartDate, localEndDate); return results.OrderBy(r => r.Value); } private static void FillMissingDaysAndFormats(List> results, DateTime startDate, DateTime endDate) { - if (results.Count == 0) - return; - var validFormats = Enum.GetValues() .Where(f => f != MangaFormat.Unknown) .ToArray(); - var minDay = results.Min(d => d.Value); - var effectiveStart = minDay > startDate.Date ? minDay : startDate.Date; - var effectiveEnd = endDate.Date < DateTime.UtcNow.Date ? endDate.Date : DateTime.UtcNow.Date; + DateTime effectiveStart; + + if (results.Count == 0) + { + effectiveStart = startDate.Date; + } + else + { + var minDay = results.Min(d => d.Value); + effectiveStart = minDay > startDate.Date ? minDay : startDate.Date; + } + + var effectiveEnd = endDate.Date; var existingEntries = results .Select(r => (r.Value, r.Format)) @@ -678,8 +710,7 @@ public class StatisticService(ILogger logger, DataContext cont { foreach (var format in validFormats) { - if (existingEntries.Contains((date, format))) - continue; + if (existingEntries.Contains((date, format))) continue; results.Add(new StatCountWithFormat { @@ -863,6 +894,7 @@ public class StatisticService(ILogger logger, DataContext cont { var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId); var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId); + var userTimeZone = GetTimeZoneOrUtc(filter.TimeZoneId); var startDate = filter.StartDate?.ToUniversalTime() ?? DateTime.MinValue; var endDate = filter.EndDate?.ToUniversalTime() ?? DateTime.UtcNow; @@ -894,7 +926,7 @@ public class StatisticService(ILogger logger, DataContext cont if (sessionActivityData.Count == 0) return result; var dailyStats = sessionActivityData - .GroupBy(x => x.SessionDate) + .GroupBy(x => TimeZoneInfo.ConvertTimeFromUtc(x.SessionStartUtc, userTimeZone).Date) .Select(dayGroup => new { Date = dayGroup.Key, @@ -952,6 +984,21 @@ public class StatisticService(ILogger logger, DataContext cont return result; } + private static TimeZoneInfo GetTimeZoneOrUtc(string? timeZoneId) + { + if (string.IsNullOrEmpty(timeZoneId)) + return TimeZoneInfo.Utc; + + try + { + return TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); + } + catch (TimeZoneNotFoundException) + { + return TimeZoneInfo.Utc; + } + } + public async Task GetReadingPaceForUser(StatsFilterDto filter, int userId, int year, bool booksOnly, int requestingUserId) { var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId); @@ -1300,13 +1347,14 @@ public class StatisticService(ILogger logger, DataContext cont { var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId); var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId); + var userTimeZone = GetTimeZoneOrUtc(filter.TimeZoneId); var sessionRecordedSince = await unitOfWork.DataContext.ManualMigrationHistory - .FirstOrDefaultAsync(mm => mm.Name == MigrateProgressToReadingSessions.Name); + .FirstOrDefaultAsync(mm => mm.Name == MigrateProgressToReadingSessions.Name); if (sessionRecordedSince == null) { - logger.LogWarning("{Migration} never happened? Cannot compute time by hour", MigrateProgressToReadingSessions.Name); + logger.LogWarning("{Migration} never happened! Cannot compute time by hour", MigrateProgressToReadingSessions.Name); return null; } @@ -1316,24 +1364,37 @@ public class StatisticService(ILogger logger, DataContext cont var sessions = await context.AppUserReadingSessionActivityData .ApplyStatsFilter(filter, userId, socialPreferences, requestingUser, isAggregate: true) .Where(session => session.ReadingSession.CreatedUtc > sessionRecordedSince.RanAt) + .Select(s => new + { + s.StartTimeUtc, + s.EndTimeUtc + }) .ToListAsync(); var hourStats = sessions + .Where(s => s.EndTimeUtc.HasValue) .SelectMany(session => { var hours = new List<(DateOnly day, int hour, TimeSpan timeSpent)>(); - var current = session.StartTime; + var currentUtc = session.StartTimeUtc; + var endUtc = session.EndTimeUtc!.Value; - while (current < session.EndTime) + while (currentUtc < endUtc) { - var hourEnd = current.AddHours(1); - var sessionEnd = session.EndTime ?? current; - var endOfPeriod = new[] { hourEnd, sessionEnd }.Min(); + var currentLocal = TimeZoneInfo.ConvertTimeFromUtc(currentUtc, userTimeZone); - var timeSpent = endOfPeriod - current; - hours.Add((DateOnly.FromDateTime(current), current.Hour, timeSpent)); + // Calculate end of current hour in local time, then convert back to UTC + var localHourEnd = new DateTime( + currentLocal.Year, currentLocal.Month, currentLocal.Day, + currentLocal.Hour, 0, 0, DateTimeKind.Unspecified).AddHours(1); + var hourEndUtc = TimeZoneInfo.ConvertTimeToUtc(localHourEnd, userTimeZone); - current = endOfPeriod; + var endOfPeriod = hourEndUtc < endUtc ? hourEndUtc : endUtc; + var timeSpent = endOfPeriod - currentUtc; + + hours.Add((DateOnly.FromDateTime(currentLocal), currentLocal.Hour, timeSpent)); + + currentUtc = endOfPeriod; } return hours; @@ -1355,7 +1416,7 @@ public class StatisticService(ILogger logger, DataContext cont .Select(hour => new StatCount { Value = hour, - Count = (long) Math.Ceiling(hourStats.TryGetValue(hour, out var value) ? value : 0), + Count = (long)Math.Ceiling(hourStats.TryGetValue(hour, out var value) ? value : 0), }) .ToList(); @@ -1598,6 +1659,149 @@ public class StatisticService(ILogger logger, DataContext cont return results; } + public async Task> GetReadingHistoryItems(StatsFilterDto filter, UserParams userParams, int userId, int requestingUserId) + { + var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId); + var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId); + var userTimeZone = GetTimeZoneOrUtc(filter.TimeZoneId); + + var query = context.AppUserReadingSessionActivityData + .ApplyStatsFilter(filter, userId, socialPreferences, requestingUser, isAggregate: false, onlyCompleted: false) + .WhereIf(filter.Libraries.Count > 0, a => filter.Libraries.Contains(a.LibraryId)) + .Select(a => new + { + a.Id, + a.AppUserReadingSessionId, + a.StartTimeUtc, + EndTimeUtc = a.EndTimeUtc!.Value, + + a.SeriesId, + SeriesName = a.Series.Name, + SeriesFormat = a.Series.Format, + + // Chapter fields for ChapterDto + a.ChapterId, + ChapterNumber = a.Chapter.Number, + ChapterRange = a.Chapter.Range, + ChapterTitle = a.Chapter.Title, + ChapterTitleName = a.Chapter.TitleName, + ChapterIsSpecial = a.Chapter.IsSpecial, + + // Volume fields for VolumeDto + VolumeId = a.Chapter.VolumeId, + VolumeNumber = a.Chapter.Volume.Number, + VolumeName = a.Chapter.Volume.Name, + VolumeChapters = a.Chapter.Volume.Chapters.Select(c => c.Id).ToList(), // Just need count, but need list for IsLooseLeaf/IsSpecial checks + + a.LibraryId, + LibraryName = a.Library.Name, + LibraryType = a.Library.Type, + + a.PagesRead, + a.WordsRead, + a.StartPage, + a.EndPage, + TotalPages = a.Chapter.Pages, + }) + .Where(a => a.EndTimeUtc != null) + .OrderByDescending(a => a.StartTimeUtc); + + // Get total count before pagination + var totalCount = await query.CountAsync(); + + // Paginate and materialize + var items = await query + .Skip((userParams.PageNumber - 1) * userParams.PageSize) + .Take(userParams.PageSize) + .ToListAsync(); + + var libraryTypes = items.Select(i => i.LibraryType).Distinct().ToList(); + var namingContexts = new Dictionary(); + + foreach (var libType in libraryTypes) + { + namingContexts[libType] = await LocalizedNamingContext.CreateAsync( + namingService, localizationService, userId, libType); + } + + // Convert to DTOs with local time + var dtos = items + .GroupBy(a => new { a.AppUserReadingSessionId, a.SeriesId }) + .Select(x => + { + var first = x.First(); + var startTime = x.Min(s => s.StartTimeUtc); + var endTime = x.Max(s => s.EndTimeUtc); + var namingContext = namingContexts[first.LibraryType]; + + var totalPages = x.Sum(s => s.TotalPages); + + var localStart = TimeZoneInfo.ConvertTimeFromUtc(startTime, userTimeZone); + return new ReadingHistoryItemDto + { + SessionDataIds = x.Select(s => s.Id).ToList(), + SessionId = first.AppUserReadingSessionId, + StartTimeUtc = startTime, + EndTimeUtc = endTime, + LocalDate = localStart.Date, + + SeriesId = first.SeriesId, + SeriesName = first.SeriesName, + SeriesFormat = first.SeriesFormat, + + Chapters = x.Select(s => + { + // Build minimal DTOs for naming + var chapterDto = new ChapterDto + { + Id = s.ChapterId, + Number = s.ChapterNumber, + Range = s.ChapterRange, + Title = s.ChapterTitle, + TitleName = s.ChapterTitleName, + IsSpecial = s.ChapterIsSpecial, + }; + + var volumeDto = new VolumeDto + { + Id = s.VolumeId, + Number = s.VolumeNumber, + Name = s.VolumeName, + Chapters = s.VolumeChapters.Select(id => new ChapterDto { Id = id }).ToList(), + }; + + return new ReadingHistoryChapterItemDto + { + ChapterId = s.ChapterId, + Label = namingContext.BuildChapterTitle(volumeDto, chapterDto), + StartTimeUtc = s.StartTimeUtc, + EndTimeUtc = s.EndTimeUtc, + DurationSeconds = (int) (s.EndTimeUtc - s.StartTimeUtc).TotalSeconds, + + PagesRead = s.PagesRead, + WordsRead = s.WordsRead, + + StartPage = s.StartPage, + EndPage = s.EndPage, + TotalPages = s.TotalPages, + Completed = s.EndPage >= s.TotalPages, + }; + }).OrderBy(c => c.StartTimeUtc).ToList(), + + LibraryId = first.LibraryId, + LibraryName = first.LibraryName, + + PagesRead = x.Sum(s => s.PagesRead), + WordsRead = x.Sum(s => s.WordsRead), + DurationSeconds = (int)(endTime - startTime).TotalSeconds, + + TotalPages = totalPages, + }; + }).ToList(); + + return PagedList.Create(dtos, totalCount, userParams.PageNumber, userParams.PageSize); + } + private async Task GetAuthorsCount(HashSet chapterIds) { if (chapterIds.Count == 0) return 0; @@ -1670,19 +1874,28 @@ public class StatisticService(ILogger logger, DataContext cont { var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId); var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId); + var userTimeZone = GetTimeZoneOrUtc(filter.TimeZoneId); - return await context.AppUserReadingSessionActivityData + var rawData = await context.AppUserReadingSessionActivityData .ApplyStatsFilter(filter, userId, socialPreferences, requestingUser, isAggregate: true) - .GroupBy(s => new {s.ReadingSession.CreatedUtc.Year, s.ReadingSession.CreatedUtc.Month}) - .Select(g => new StatCount() + .Select(s => s.ReadingSession.CreatedUtc) + .ToListAsync(); + + return rawData + .Select(utc => TimeZoneInfo.ConvertTimeFromUtc(utc, userTimeZone)) + .GroupBy(local => new { local.Year, local.Month }) + .Select(g => new StatCount { - Value = new YearMonthGroupingDto() + Value = new YearMonthGroupingDto { Year = g.Key.Year, Month = g.Key.Month, }, Count = g.Count(), - }).ToListAsync(); + }) + .OrderBy(s => s.Value.Year) + .ThenBy(s => s.Value.Month) + .ToList(); } public async Task> GetMostReadAuthors(StatsFilterDto filter, int userId, int requestingUserId) diff --git a/API/Services/Store/UserContext.cs b/API/Services/Store/UserContext.cs index c9a4b1dfc..27650d063 100644 --- a/API/Services/Store/UserContext.cs +++ b/API/Services/Store/UserContext.cs @@ -57,6 +57,7 @@ public class UserContext : IUserContext public int GetUserIdOrThrow() { + // TODO: Refactor this to use ProblemDetails and handle appropriately return _userId ?? throw new KavitaException("User is not authenticated"); } diff --git a/UI/Web/src/app/_models/stats/reading-history-item.ts b/UI/Web/src/app/_models/stats/reading-history-item.ts new file mode 100644 index 000000000..cf41179c5 --- /dev/null +++ b/UI/Web/src/app/_models/stats/reading-history-item.ts @@ -0,0 +1,42 @@ +import {MangaFormat} from "../manga-format"; + +export interface ReadingHistoryItem { + sessionDataIds: number[]; + id: number; + sessionId: number; + startTimeUtc: string; + endTimeUtc: string; + localDate: string; + + seriesId: number; + seriesName: string; + seriesFormat: MangaFormat; + + chapters: ReadingHistoryChapterItem[]; + + libraryId: number; + libraryName: string; + + pagesRead: number; + wordsRead: number; + durationSeconds: number; + + totalPages: number; +} + +export interface ReadingHistoryChapterItem { + chapterId: number; + label: string; + + startTimeUtc: string; + endTimeUtc: string; + + pagesRead: number; + wordsRead: number; + durationSeconds: number; + + startPage: number; + endPage: number; + totalPages: number; + completed: boolean; +} diff --git a/UI/Web/src/app/_services/statistics.service.ts b/UI/Web/src/app/_services/statistics.service.ts index 528e09045..d8c393571 100644 --- a/UI/Web/src/app/_services/statistics.service.ts +++ b/UI/Web/src/app/_services/statistics.service.ts @@ -10,7 +10,6 @@ import {ServerStatistics} from '../statistics/_models/server-statistics'; import {StatCount, StatCountWithFormat} from '../statistics/_models/stat-count'; import {PublicationStatus} from '../_models/metadata/publication-status'; import {MangaFormat} from '../_models/manga-format'; -import {TranslocoService} from "@jsverse/transloco"; import {throttleTime} from "rxjs/operators"; import {DEBOUNCE_TIME} from "../shared/_services/download.service"; import {download} from "../shared/_models/download"; @@ -33,6 +32,9 @@ import {Series} from "../_models/series"; import {Tag} from "../_models/tag"; import {Person, PersonRole} from "../_models/metadata/person"; import {ReadingList} from "../_models/reading-list"; +import {ReadingHistoryItem} from "../_models/stats/reading-history-item"; +import {PaginatedResult} from "../_models/pagination"; +import {UtilityService} from "../shared/_services/utility.service"; export enum DayOfWeek { @@ -54,7 +56,7 @@ export class StatisticsService { baseUrl = environment.apiUrl; - translocoService = inject(TranslocoService); + utilityService = inject(UtilityService); publicationStatusPipe = new PublicationStatusPipe(); @@ -168,6 +170,23 @@ export class StatisticsService { }); } + getReadingHistoryItemsResource(statsFilter: () => StatsFilter | undefined, userId: () => number, pageNum: () => number = () => 1, itemsPerPage: () => number = () => 30) { + return httpResource>(() => { + const filter = statsFilter(); + const id = userId(); + if (!filter || !id) return undefined; + + let params = this.filterHttpParams(filter, id); + params = this.utilityService.addPaginationIfExists(params, pageNum(), itemsPerPage()); + params = params.set('timeZoneId', Intl.DateTimeFormat().resolvedOptions().timeZone); + + return { + url: `${this.baseUrl}stats/history/${id}`, + params + }; + }); + } + getDayBreakdown(userId = 0) { return this.httpClient.get>>(this.baseUrl + 'stats/day-breakdown?userId=' + userId); } @@ -219,6 +238,10 @@ export class StatisticsService { params = params.set('endDate', filter.timeFilter.endDate.toISOString()); } + if (filter.timezone) { + params = params.set('timeZoneId', filter.timezone); + } + for (let library of filter.libraries) { params = params.append('libraries', library) } @@ -294,4 +317,42 @@ export class StatisticsService { return httpResource(() => this.baseUrl + `stats/total-reads?userId=${userId()}`).asReadonly(); } + getReadingHistory( + filter: StatsFilter, + userId: number, + pageNum: number = 1, + itemsPerPage: number = 30 + ) { + let params = this.filterHttpParams(filter, userId); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + + return this.httpClient.get( + `${this.baseUrl}stats/reading-history`, + { observe: 'response', params } + ).pipe( + map(response => this.utilityService.createPaginatedResult(response)) + ); + } + + getReadingHistoryResource( + statsFilter: () => StatsFilter | undefined, + userId: () => number, + pageNum: () => number = () => 1, + itemsPerPage: () => number = () => 30 + ) { + return httpResource(() => { + const filter = statsFilter(); + const id = userId(); + if (!filter || !id) return undefined; + + let params = this.filterHttpParams(filter, id); + params = this.utilityService.addPaginationIfExists(params, pageNum(), itemsPerPage()); + + return { + url: `${this.baseUrl}stats/reading-history`, + params + }; + }); + } + } diff --git a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts index 14c02b7c5..f452a95e5 100644 --- a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts +++ b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts @@ -34,7 +34,6 @@ import {SettingItemComponent} from "../../settings/_components/setting-item/sett import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component"; import {DefaultModalOptions} from "../../_models/default-modal-options"; import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; -import {AnnotationService} from "../../_services/annotation.service"; import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component"; interface AdhocTask { @@ -46,10 +45,10 @@ interface AdhocTask { } @Component({ - selector: 'app-manage-tasks-settings', - templateUrl: './manage-tasks-settings.component.html', - styleUrls: ['./manage-tasks-settings.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'app-manage-tasks-settings', + templateUrl: './manage-tasks-settings.component.html', + styleUrls: ['./manage-tasks-settings.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ReactiveFormsModule, AsyncPipe, TitleCasePipe, DefaultValuePipe, TranslocoModule, TranslocoLocaleModule, UtcToLocalTimePipe, SettingItemComponent, SettingButtonComponent, NgxDatatableModule, ResponsiveTableComponent] @@ -63,7 +62,6 @@ export class ManageTasksSettingsComponent implements OnInit { private readonly serverService = inject(ServerService); private readonly modalService = inject(NgbModal); private readonly downloadService = inject(DownloadService); - private readonly annotationService = inject(AnnotationService); serverSettings!: ServerSettings; settingsForm: FormGroup = new FormGroup({}); diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.ts b/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.ts index 8dee18535..bb0d584ec 100644 --- a/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.ts +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.ts @@ -52,7 +52,7 @@ export class ViewBookmarkDrawerComponent { protected readonly imageService = inject(ImageService); - chapterId = input.required(); + chapterId = model.required(); bookmarks = signal([]); /** * Current Page diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.ts b/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.ts index 5d13d1090..5f5f192bf 100644 --- a/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.ts +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.ts @@ -32,7 +32,7 @@ export class ViewTocDrawerComponent { private readonly cdRef = inject(ChangeDetectorRef); private readonly bookService = inject(BookService); - chapterId = input.required(); + chapterId = model.required(); /** * Current Page */ diff --git a/UI/Web/src/app/profile/_components/profile-activity/profile-activity.component.html b/UI/Web/src/app/profile/_components/profile-activity/profile-activity.component.html new file mode 100644 index 000000000..1518058f8 --- /dev/null +++ b/UI/Web/src/app/profile/_components/profile-activity/profile-activity.component.html @@ -0,0 +1,152 @@ + +
+ +
+ + @if (isLoading()) { +
+ +
+ } @else if (pagination()?.totalItems === 0) { + + } @else { + @if (totalPages() > 1) { + + } + +
+ @for (entry of currentEntries(); track entry.sessionId) { +
+
+
+ +
+ +
+
+
+ + {{ entry.seriesName }} + + {{ entry.seriesFormat | mangaFormat }} +
+ +
+ + + + +
+ @for (chapter of entry.chapters.slice(0, 5); track chapter.chapterId) { +
+ +
+ } +
+
+
+
+ } +
+ + @if (totalPages() > 1) { + + } + } +
+ + + @let chapter = item.value; + +
+ +
+ + + +
+ + +
+ + @if (label) { + + {{label}} + + } + + + + {{ entry.durationSeconds | duration }} + + + @if (entry.pagesRead > 0) { + + + {{t('pages-count', {num: entry.pagesRead | compactNumber})}} + + } + + @if (entry.wordsRead > 0) { + + + {{t('words-count', {num: entry.wordsRead | compactNumber})}} + + } + + + @if (entry.completed) { + + } + {{ formatProgress(entry) }} + + + + {{ entry.startTimeUtc | date:'shortTime' }} + + + @if (showInfo) { + + } +
+
diff --git a/UI/Web/src/app/profile/_components/profile-activity/profile-activity.component.scss b/UI/Web/src/app/profile/_components/profile-activity/profile-activity.component.scss new file mode 100644 index 000000000..b5dd6834e --- /dev/null +++ b/UI/Web/src/app/profile/_components/profile-activity/profile-activity.component.scss @@ -0,0 +1,68 @@ +.activity-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; +} + +.activity-entry { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.75rem; + background: var(--bs-body-bg); + border-radius: var(--bs-border-radius); + border: 1px solid var(--bs-border-color-translucent); + + &.completed { + border-left: 3px solid var(--bs-success); + } +} + +.entry-date { + flex-shrink: 0; +} + +.chapter-cover { + cursor: pointer; + transition: transform 0.15s ease; + + &:hover { + transform: scale(1.05); + } + + &:focus-visible { + outline: 2px solid var(--bs-primary); + outline-offset: 2px; + } +} + +.loading-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; +} + +// Mobile optimizations +@media (max-width: 575.98px) { + .activity-list { + padding: 0.5rem; + } + + .activity-entry { + padding: 0.5rem; + } + + // Make pagination buttons larger for touch + ::ng-deep .pagination { + .page-link { + padding: 0.5rem 0.75rem; + min-width: 44px; // Minimum touch target + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; + } + } +} diff --git a/UI/Web/src/app/profile/_components/profile-activity/profile-activity.component.ts b/UI/Web/src/app/profile/_components/profile-activity/profile-activity.component.ts new file mode 100644 index 000000000..d29df6b39 --- /dev/null +++ b/UI/Web/src/app/profile/_components/profile-activity/profile-activity.component.ts @@ -0,0 +1,182 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + inject, + input, + signal, + TemplateRef, + viewChild +} from '@angular/core'; +import {takeUntilDestroyed, toObservable} from '@angular/core/rxjs-interop'; +import {combineLatest, distinctUntilChanged, filter, tap} from 'rxjs'; +import {MemberInfo} from '../../../_models/user/member-info'; +import {translate, TranslocoDirective} from '@jsverse/transloco'; +import {StatisticsService} from '../../../_services/statistics.service'; +import {ReadingHistoryChapterItem, ReadingHistoryItem} from '../../../_models/stats/reading-history-item'; +import {LoadingComponent} from '../../../shared/loading/loading.component'; +import {DatePipe, DOCUMENT, NgTemplateOutlet, TitleCasePipe} from '@angular/common'; +import {StatsFilter} from '../../../statistics/_models/stats-filter'; +import {RouterLink} from '@angular/router'; +import { + LibraryAndTimeSelectorComponent +} from '../../../statistics/_components/library-and-time-selector/library-and-time-selector.component'; +import {StatsNoDataComponent} from '../../../common/stats-no-data/stats-no-data.component'; +import {MangaFormatPipe} from '../../../_pipes/manga-format.pipe'; +import {TagBadgeComponent} from '../../../shared/tag-badge/tag-badge.component'; +import {ImageComponent} from '../../../shared/image/image.component'; +import {ImageService} from '../../../_services/image.service'; +import {ModalService} from '../../../_services/modal.service'; +import {ListSelectModalComponent} from '../../../shared/_components/list-select-modal/list-select-modal.component'; +import {CompactNumberPipe} from '../../../_pipes/compact-number.pipe'; +import {DurationPipe} from '../../../_pipes/duration.pipe'; +import {Pagination} from '../../../_models/pagination'; +import {NgbPagination, NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; + + +@Component({ + selector: 'app-profile-activity', + imports: [ + TranslocoDirective, + LoadingComponent, + DatePipe, + RouterLink, + LibraryAndTimeSelectorComponent, + StatsNoDataComponent, + MangaFormatPipe, + TagBadgeComponent, + ImageComponent, + TitleCasePipe, + CompactNumberPipe, + NgTemplateOutlet, + DurationPipe, + NgbPagination, + NgbTooltip, + ], + templateUrl: './profile-activity.component.html', + styleUrl: './profile-activity.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProfileActivityComponent { + + private readonly statsService = inject(StatisticsService); + protected readonly imageService = inject(ImageService); + private readonly modalService = inject(ModalService); + private readonly destroyRef = inject(DestroyRef); + private readonly document = inject(DOCUMENT); + + memberInfo = input.required(); + filter = signal(undefined); + + chapterInfoRow = viewChild.required>('chapterInfoRow'); + readStatsTemplate = viewChild.required>('readStats'); + + protected readonly pageSize = 30; + + // State signals + protected currentEntries = signal([]); + protected pagination = signal(null); + protected isLoading = signal(false); + protected currentPage = signal(1); + + protected totalPages = computed(() => this.pagination()?.totalPages ?? 1); + protected totalItems = computed(() => this.pagination()?.totalItems ?? 0); + + constructor() { + // React to filter/member changes - reset to page 1 + combineLatest([ + toObservable(this.filter), + toObservable(this.memberInfo) + ]).pipe( + filter(([f, m]) => !!f && !!m?.id), + distinctUntilChanged((prev, curr) => + JSON.stringify(prev[0]) === JSON.stringify(curr[0]) && prev[1]?.id === curr[1]?.id + ), + tap(() => { + this.currentPage.set(1); + this.loadPage(1); + }), + takeUntilDestroyed(this.destroyRef) + ).subscribe(); + } + + private loadPage(page: number): void { + const f = this.filter(); + const memberId = this.memberInfo()?.id; + + if (!f || !memberId) return; + + this.isLoading.set(true); + + this.statsService.getReadingHistory(f, memberId, page, this.pageSize) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (result) => { + this.currentEntries.set(result.result); + this.pagination.set(result.pagination); + this.currentPage.set(page); + this.isLoading.set(false); + }, + error: (err) => { + console.error('Failed to load reading history', err); + this.isLoading.set(false); + } + }); + } + + protected onPageChange(page: number): void { + if (page === this.currentPage() || this.isLoading()) return; + + this.loadPage(page); + this.document.querySelector('.activity-list')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + + protected formatProgress(entry: ReadingHistoryItem): string { + return `${entry.pagesRead}/${entry.totalPages}`; + } + + /** + * Returns relative date string for today/yesterday, otherwise formatted date + */ + protected formatEntryDate(entry: ReadingHistoryItem): string { + const [year, month, day] = entry.localDate.substring(0, 10).split('-').map(Number); + const entryDate = new Date(year, month - 1, day); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + if (entryDate.getTime() === today.getTime()) { + return translate('profile-activity.today'); + } + if (entryDate.getTime() === yesterday.getTime()) { + return translate('profile-activity.yesterday'); + } + + // Format as "Jan 4, 2025" + return entryDate.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric' + }); + } + + protected displayInfo(item: ReadingHistoryItem): void { + const [_, component] = this.modalService.open(ListSelectModalComponent, { + size: 'lg', + centered: true + }); + + component.title.set(translate('profile-activity.chapter-detail-modal-title', { seriesName: item.seriesName })); + component.showConfirm.set(false); + component.inputItems.set(item.chapters.map(c => ({ value: c, label: `${c.label}` }))); + component.itemTemplate.set(this.chapterInfoRow()); + component.itemsBeforeVirtual.set(5); + } + + updateFilter(event: StatsFilter): void { + this.filter.set(event); + } +} diff --git a/UI/Web/src/app/profile/_components/profile/profile.component.html b/UI/Web/src/app/profile/_components/profile/profile.component.html index 11d55114d..d2c57c5a0 100644 --- a/UI/Web/src/app/profile/_components/profile/profile.component.html +++ b/UI/Web/src/app/profile/_components/profile/profile.component.html @@ -82,6 +82,15 @@ } + +
  • + {{t(TabID.Activity)}} + + @defer (when activeTabId === TabID.Activity; prefetch on idle) { + + } + +
  • diff --git a/UI/Web/src/app/profile/_components/profile/profile.component.ts b/UI/Web/src/app/profile/_components/profile/profile.component.ts index 2be9d0386..cea657fed 100644 --- a/UI/Web/src/app/profile/_components/profile/profile.component.ts +++ b/UI/Web/src/app/profile/_components/profile/profile.component.ts @@ -33,11 +33,13 @@ import {TimeDurationPipe} from "../../../_pipes/time-duration.pipe"; import {NavTabUrlDirective} from "../../../_directives/nav-tab-url.directive"; import {Title} from "@angular/platform-browser"; import {AccountService} from "../../../_services/account.service"; +import {ProfileActivityComponent} from "../profile-activity/profile-activity.component"; enum TabID { Overview = 'overview-tab', Stats = 'stats-tab', Reviews = 'reviews-tab', + Activity = 'activity-tab' } @Component({ @@ -65,6 +67,7 @@ enum TabID { SentenceCasePipe, TimeDurationPipe, NavTabUrlDirective, + ProfileActivityComponent, ], templateUrl: './profile.component.html', styleUrl: './profile.component.scss', diff --git a/UI/Web/src/app/shared/_components/list-select-modal/list-select-modal.component.html b/UI/Web/src/app/shared/_components/list-select-modal/list-select-modal.component.html index 29756c685..36e4bb322 100644 --- a/UI/Web/src/app/shared/_components/list-select-modal/list-select-modal.component.html +++ b/UI/Web/src/app/shared/_components/list-select-modal/list-select-modal.component.html @@ -15,6 +15,7 @@ @let itemsValue = items(); @let filteredItemsValue = filteredItems(); @let descriptionValue = description(); + @let itemsBeforeVirtualValue = itemsBeforeVirtual(); @if (descriptionValue) { {{descriptionValue}} @@ -30,7 +31,7 @@ @if (itemsValue.length > itemsBeforeFilter()) {
    - +
    @@ -41,16 +42,42 @@
      @if (!loading()) { - @for(item of filteredItemsValue; track $index) { -
    • -
      - - + @if (itemsBeforeVirtualValue && itemsValue.length > itemsBeforeVirtualValue) { + + +
      + @for (item of scroll.viewPortItems; track $index) { +
    • +
      + + + + +
      +
    • + }
    - + + + + } @else { + @for(item of filteredItemsValue; track $index) { +
  • +
    + + + + +
    +
  • + } } } @@ -59,8 +86,12 @@ @if (showFooter()) { } diff --git a/UI/Web/src/app/shared/_components/list-select-modal/list-select-modal.component.scss b/UI/Web/src/app/shared/_components/list-select-modal/list-select-modal.component.scss index bc7d44aa0..fed940289 100644 --- a/UI/Web/src/app/shared/_components/list-select-modal/list-select-modal.component.scss +++ b/UI/Web/src/app/shared/_components/list-select-modal/list-select-modal.component.scss @@ -16,3 +16,8 @@ background-color : var(--primary-color); } } + +.virtual-scroller, virtual-scroller { + width: 100%; + height: 400px; +} diff --git a/UI/Web/src/app/shared/_components/list-select-modal/list-select-modal.component.ts b/UI/Web/src/app/shared/_components/list-select-modal/list-select-modal.component.ts index f67fb1a34..fcef6d771 100644 --- a/UI/Web/src/app/shared/_components/list-select-modal/list-select-modal.component.ts +++ b/UI/Web/src/app/shared/_components/list-select-modal/list-select-modal.component.ts @@ -18,6 +18,7 @@ import {toSignal} from "@angular/core/rxjs-interop"; import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe"; import {NgTemplateOutlet} from "@angular/common"; import {LoadingComponent} from "../../loading/loading.component"; +import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller"; export type ListSelectionItem = { label: string, @@ -32,7 +33,8 @@ export type ListSelectionItem = { ReactiveFormsModule, SentenceCasePipe, NgTemplateOutlet, - LoadingComponent + LoadingComponent, + VirtualScrollerModule ], templateUrl: './list-select-modal.component.html', styleUrl: './list-select-modal.component.scss', @@ -57,12 +59,14 @@ export class ListSelectModalComponent { interceptConfirm = model<((selection: T|T[]) => void) | null>(null); itemsBeforeFilter = model(8); + itemsBeforeVirtual = model(null); requireConfirmation = model(false); showFooter = model(true); + showConfirm = model(true); multiSelect = model(false); hideItemsWhenInvalid = model(false); - itemTemplate = input | null>(null); + itemTemplate = model | null>(null); loading = model(false); @@ -104,7 +108,7 @@ export class ListSelectModalComponent { protected filteredItems = computed(() => { const items = this.items(); - const filter = this.filterQuery().toLowerCase(); + const filter = (this.filterQuery() ?? '').toLowerCase(); if (!filter) return items; diff --git a/UI/Web/src/app/statistics/_components/activity-graph/activity-graph.component.html b/UI/Web/src/app/statistics/_components/activity-graph/activity-graph.component.html index b0ef01136..7e78ee676 100644 --- a/UI/Web/src/app/statistics/_components/activity-graph/activity-graph.component.html +++ b/UI/Web/src/app/statistics/_components/activity-graph/activity-graph.component.html @@ -1,13 +1,22 @@
    -

    +

    {{ t('title') }} {{ timeFrameLabel() }} -

    + @if (readingActivityResource.hasValue()) { + @let aggregatedCountValue = aggregatedCount(); + @if (aggregatedCountValue.totalPages > 0 || aggregatedCountValue.totalWords > 0) { +
    {{ t('aggregated-count', { + year: year(), + totalPages: aggregatedCountValue.totalPages | compactNumber, + totalWords: aggregatedCountValue.totalWords | compactNumber + }) }}
    + } +
    @@ -56,7 +65,7 @@ [tooltipClass]="'activity-tooltip'" > } @else { - + } } @@ -80,15 +89,6 @@ {{ t('more') }} - - @let aggregatedCountValue = aggregatedCount(); - @if (aggregatedCountValue.totalPages > 0 || aggregatedCountValue.totalWords > 0) { - {{ t('aggregated-count', { - year: year(), - totalPages: aggregatedCountValue.totalPages | compactNumber, - totalWords: aggregatedCountValue.totalWords | compactNumber - }) }} - } diff --git a/UI/Web/src/app/statistics/_components/activity-graph/activity-graph.component.scss b/UI/Web/src/app/statistics/_components/activity-graph/activity-graph.component.scss index 4b2c48b8c..1d29fd73a 100644 --- a/UI/Web/src/app/statistics/_components/activity-graph/activity-graph.component.scss +++ b/UI/Web/src/app/statistics/_components/activity-graph/activity-graph.component.scss @@ -85,7 +85,10 @@ outline: var(--activity-graph-cell-radius) solid var(--activity-graph-cell-focus-outline-color); outline-offset: 1px; } +} +.out-of-year-cell { + outline: none; } .empty-cell { diff --git a/UI/Web/src/app/statistics/_components/library-and-time-selector/library-and-time-selector.component.ts b/UI/Web/src/app/statistics/_components/library-and-time-selector/library-and-time-selector.component.ts index 54893993b..1a65e047a 100644 --- a/UI/Web/src/app/statistics/_components/library-and-time-selector/library-and-time-selector.component.ts +++ b/UI/Web/src/app/statistics/_components/library-and-time-selector/library-and-time-selector.component.ts @@ -123,6 +123,8 @@ export class LibraryAndTimeSelectorComponent implements OnInit { takeUntilDestroyed(), ).subscribe(value => { const filter = value as StatsFilter; + filter.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + this.filterChange.emit(filter); this.yearChange.emit(filter.timeFilter?.endDate?.getFullYear() ?? new Date().getFullYear()); }); diff --git a/UI/Web/src/app/statistics/_models/stats-filter.ts b/UI/Web/src/app/statistics/_models/stats-filter.ts index c03f1f850..76d4ce320 100644 --- a/UI/Web/src/app/statistics/_models/stats-filter.ts +++ b/UI/Web/src/app/statistics/_models/stats-filter.ts @@ -5,4 +5,6 @@ export type StatsFilter = { endDate: Date | null, }, libraries: number[], + timezone: string | null, } + diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index c247a6612..4551f7e4c 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -2800,6 +2800,7 @@ "overview-tab": "{{tabs.overview-tab}}", "stats-tab": "{{tabs.stats-tab}}", "reviews-tab": "{{tabs.reviews-tab}}", + "activity-tab": "{{tabs.activity-tab}}", "no-reviews": "No Reviews yet", "total-reads-badge": "{{reads}} Reads", "k+-badge": "K+ Subscriber" @@ -2833,6 +2834,24 @@ "reset-image-alt": "Reset Image" }, + "profile-activity": { + "time-filter-title": "{{username}}'s activity through", + "pages-count": "{{series-detail.pages-count}}", + "words-count": "{{series-detail.words-count}}", + "no-data": "{{common.no-data}}", + "duration": "Duration", + "pages-read": "{{reading-pace.pages-read}}", + "words-read": "{{reading-pace.words-read}}", + "progress": "Progress", + "time": "Time", + "info-alt": "More Info", + "chapter-detail-modal-title": "{{seriesName}}", + "today": "Today", + "yesterday": "Yesterday", + "pagination-label": "Activity pagination", + "page-info": "Page {{current}} of {{total}} ({{items}} total records)" + }, + "preferred-format": { "title": "Preferred format", "sub-title": "{{name}} prefers to read {{format}}", @@ -3656,7 +3675,8 @@ "smart-filters-tab": "Smart Filters", "annotations-tab": "Annotations", "overview-tab": "Overview", - "management-tab": "Management" + "management-tab": "Management", + "activity-tab": "Activity" }, "common": { diff --git a/UI/Web/src/theme/components/_stat-card.scss b/UI/Web/src/theme/components/_stat-card.scss index 3b348adca..9421b00ee 100644 --- a/UI/Web/src/theme/components/_stat-card.scss +++ b/UI/Web/src/theme/components/_stat-card.scss @@ -5,7 +5,7 @@ .stats-title { font-size: 0.875rem; - color: var(--text-muted-color); //#8b95a5; + color: var(--text-muted-color); text-transform: uppercase; letter-spacing: 0.5px; }