From dad212bfb96487db431bdc7e2827fa53163ab3ed Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Sat, 29 Mar 2025 19:47:53 -0500 Subject: [PATCH] Reading List Detail Overhaul + More Bugfixes and Polish (#3687) Co-authored-by: Yongun Seong --- API.Tests/Helpers/OrderableHelperTests.cs | 102 +- API.Tests/Helpers/StringHelperTests.cs | 15 + API/Controllers/LibraryController.cs | 4 +- API/Controllers/LocaleController.cs | 4 +- API/Controllers/ReaderController.cs | 2 +- API/Controllers/ReadingListController.cs | 57 +- API/Controllers/SeriesController.cs | 3 +- API/DTOs/ReadingLists/ReadingListCast.cs | 20 + API/DTOs/ReadingLists/ReadingListDto.cs | 5 + API/DTOs/ReadingLists/ReadingListInfoDto.cs | 26 + API/DTOs/ReadingLists/ReadingListItemDto.cs | 4 +- API/DTOs/UserPreferencesDto.cs | 7 + API/Data/DataContext.cs | 3 + ...012_AutomaticWebtoonReaderMode.Designer.cs | 3403 +++++++++++++++++ ...250328125012_AutomaticWebtoonReaderMode.cs | 29 + .../Migrations/DataContextModelSnapshot.cs | 181 +- API/Data/Repositories/PersonRepository.cs | 22 +- .../Repositories/ReadingListRepository.cs | 106 +- .../Repositories/ScrobbleEventRepository.cs | 2 +- API/Entities/AppUserPreferences.cs | 4 + .../Scrobble/ScrobbleEventSortField.cs | 3 +- .../QueryExtensions/QueryableExtensions.cs | 2 + API/Helpers/StringHelper.cs | 14 + API/Services/MediaConversionService.cs | 4 + API/Services/MetadataService.cs | 4 + API/Services/Plus/ExternalMetadataService.cs | 24 +- API/Services/Tasks/Metadata/CoverDbService.cs | 10 +- API/Services/Tasks/VersionUpdaterService.cs | 28 +- UI/Web/package-lock.json | 15 +- .../app/_models/preferences/preferences.ts | 94 +- UI/Web/src/app/_models/reading-list.ts | 35 +- .../scrobbling/scrobble-event-filter.ts | 3 +- .../series-detail/hour-estimate-range.ts | 5 +- UI/Web/src/app/_services/reader.service.ts | 28 +- .../src/app/_services/reading-list.service.ts | 36 +- UI/Web/src/app/_services/version.service.ts | 46 +- .../details-tab/details-tab.component.html | 22 +- .../details-tab/details-tab.component.ts | 11 +- .../match-series-result-item.component.html | 2 +- .../match-series-result-item.component.ts | 26 +- .../user-scrobble-history.component.html | 1 + .../user-scrobble-history.component.ts | 28 +- .../manage-scrobble-errors.component.html | 10 +- .../manage-scrobble-errors.component.ts | 30 +- .../changelog/changelog.component.ts | 5 +- .../edit-series-relation.component.html | 61 +- .../edit-series-relation.component.ts | 28 +- .../series-card/series-card.component.html | 2 +- .../infinite-scroller.component.ts | 57 +- .../manga-reader/manga-reader.component.ts | 7 +- .../_service/manga-reader.service.ts | 104 +- .../draggable-ordered-list.component.html | 92 +- .../draggable-ordered-list.component.ts | 14 +- .../reading-list-detail.component.html | 402 +- .../reading-list-detail.component.scss | 3 + .../reading-list-detail.component.ts | 223 +- .../reading-list-item.component.html | 29 +- .../reading-list-item.component.ts | 2 + .../series-detail.component.html | 2 +- .../series-detail/series-detail.component.ts | 20 +- .../setting-item/setting-item.component.html | 2 +- .../setting-title.component.html | 2 +- .../shared/edit-list/edit-list.component.html | 49 +- .../shared/edit-list/edit-list.component.ts | 63 +- .../shared/read-more/read-more.component.ts | 13 +- .../library-settings-modal.component.ts | 14 +- .../manage-user-preferences.component.html | 12 + .../manage-user-preferences.component.ts | 5 +- UI/Web/src/assets/langs/en.json | 30 +- UI/Web/src/theme/components/_buttons.scss | 57 +- UI/Web/src/theme/themes/dark.scss | 7 +- 71 files changed, 5056 insertions(+), 729 deletions(-) create mode 100644 API/DTOs/ReadingLists/ReadingListCast.cs create mode 100644 API/DTOs/ReadingLists/ReadingListInfoDto.cs create mode 100644 API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.Designer.cs create mode 100644 API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.cs diff --git a/API.Tests/Helpers/OrderableHelperTests.cs b/API.Tests/Helpers/OrderableHelperTests.cs index a6d741be1..15f9e6268 100644 --- a/API.Tests/Helpers/OrderableHelperTests.cs +++ b/API.Tests/Helpers/OrderableHelperTests.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using API.Entities; using API.Helpers; @@ -49,17 +50,14 @@ public class OrderableHelperTests [Fact] public void ReorderItems_InvalidPosition_NoChange() { - // Arrange var items = new List { new AppUserSideNavStream { Id = 1, Order = 0, Name = "A" }, new AppUserSideNavStream { Id = 2, Order = 1, Name = "A" }, }; - // Act OrderableHelper.ReorderItems(items, 2, 3); // Position 3 is out of range - // Assert Assert.Equal(1, items[0].Id); // Item 1 should remain at position 0 Assert.Equal(2, items[1].Id); // Item 2 should remain at position 1 } @@ -80,7 +78,6 @@ public class OrderableHelperTests [Fact] public void ReorderItems_DoubleMove() { - // Arrange var items = new List { new AppUserSideNavStream { Id = 1, Order = 0, Name = "0" }, @@ -94,7 +91,6 @@ public class OrderableHelperTests // Move 4 -> 1 OrderableHelper.ReorderItems(items, 5, 1); - // Assert Assert.Equal(1, items[0].Id); Assert.Equal(0, items[0].Order); Assert.Equal(5, items[1].Id); @@ -109,4 +105,98 @@ public class OrderableHelperTests Assert.Equal("034125", string.Join("", items.Select(s => s.Name))); } + + private static List CreateTestReadingListItems(int count = 4) + { + var items = new List(); + + for (var i = 0; i < count; i++) + { + items.Add(new ReadingListItem() { Id = i + 1, Order = count, ReadingListId = i + 1}); + } + + return items; + } + + [Fact] + public void ReorderItems_MoveItemToBeginning_CorrectOrder() + { + var items = CreateTestReadingListItems(); + + OrderableHelper.ReorderItems(items, 3, 0); + + Assert.Equal(3, items[0].Id); + Assert.Equal(1, items[1].Id); + Assert.Equal(2, items[2].Id); + Assert.Equal(4, items[3].Id); + + for (var i = 0; i < items.Count; i++) + { + Assert.Equal(i, items[i].Order); + } + } + + [Fact] + public void ReorderItems_MoveItemToEnd_CorrectOrder() + { + var items = CreateTestReadingListItems(); + + OrderableHelper.ReorderItems(items, 1, 3); + + Assert.Equal(2, items[0].Id); + Assert.Equal(3, items[1].Id); + Assert.Equal(4, items[2].Id); + Assert.Equal(1, items[3].Id); + + for (var i = 0; i < items.Count; i++) + { + Assert.Equal(i, items[i].Order); + } + } + + [Fact] + public void ReorderItems_MoveItemToMiddle_CorrectOrder() + { + var items = CreateTestReadingListItems(); + + OrderableHelper.ReorderItems(items, 4, 2); + + Assert.Equal(1, items[0].Id); + Assert.Equal(2, items[1].Id); + Assert.Equal(4, items[2].Id); + Assert.Equal(3, items[3].Id); + + for (var i = 0; i < items.Count; i++) + { + Assert.Equal(i, items[i].Order); + } + } + + [Fact] + public void ReorderItems_MoveItemToOutOfBoundsPosition_MovesToEnd() + { + var items = CreateTestReadingListItems(); + + OrderableHelper.ReorderItems(items, 2, 10); + + Assert.Equal(1, items[0].Id); + Assert.Equal(3, items[1].Id); + Assert.Equal(4, items[2].Id); + Assert.Equal(2, items[3].Id); + + for (var i = 0; i < items.Count; i++) + { + Assert.Equal(i, items[i].Order); + } + } + + [Fact] + public void ReorderItems_NegativePosition_ThrowsArgumentException() + { + var items = CreateTestReadingListItems(); + + Assert.Throws(() => + OrderableHelper.ReorderItems(items, 2, -1) + ); + } } diff --git a/API.Tests/Helpers/StringHelperTests.cs b/API.Tests/Helpers/StringHelperTests.cs index 1eac0a6ed..8f845c9b0 100644 --- a/API.Tests/Helpers/StringHelperTests.cs +++ b/API.Tests/Helpers/StringHelperTests.cs @@ -10,6 +10,10 @@ public class StringHelperTests "

A Perfect Marriage Becomes a Perfect Affair!



Every woman wishes for that happily ever after, but when time flies by and you've become a neglected housewife, what's a woman to do?

", "

A Perfect Marriage Becomes a Perfect Affair!
Every woman wishes for that happily ever after, but when time flies by and you've become a neglected housewife, what's a woman to do?

" )] + [InlineData( + "

Blog | Twitter | Pixiv | Pawoo

", + "

Blog | Twitter | Pixiv | Pawoo

" + )] public void TestSquashBreaklines(string input, string expected) { Assert.Equal(expected, StringHelper.SquashBreaklines(input)); @@ -28,4 +32,15 @@ public class StringHelperTests { Assert.Equal(expected, StringHelper.RemoveSourceInDescription(input)); } + + + [Theory] + [InlineData( +"""Pawoo

""", +"""Pawoo

""" + )] + public void TestCorrectUrls(string input, string expected) + { + Assert.Equal(expected, StringHelper.CorrectUrls(input)); + } } diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 5405513d6..2f12aa1fe 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -213,7 +213,6 @@ public class LibraryController : BaseApiController var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username); await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24)); - _logger.LogDebug("Caching libraries for {Key}", cacheKey); return Ok(ret); } @@ -419,8 +418,7 @@ public class LibraryController : BaseApiController .Distinct() .Select(Services.Tasks.Scanner.Parser.Parser.NormalizePath); - var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder, - new List() {dto.FolderPath}); + var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder, [dto.FolderPath]); _taskScheduler.ScanFolder(seriesFolder.Keys.Count == 1 ? seriesFolder.Keys.First() : dto.FolderPath); diff --git a/API/Controllers/LocaleController.cs b/API/Controllers/LocaleController.cs index 3117a9b41..6e3a2ec78 100644 --- a/API/Controllers/LocaleController.cs +++ b/API/Controllers/LocaleController.cs @@ -46,8 +46,8 @@ public class LocaleController : BaseApiController } var ret = _localizationService.GetLocales().Where(l => l.TranslationCompletion > 0f); - await _localeCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromDays(7)); + await _localeCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromDays(1)); - return Ok(); + return Ok(ret); } } diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index d4bc8a1fe..38a5ad482 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -803,7 +803,7 @@ public class ReaderController : BaseApiController /// /// [HttpGet("time-left")] - [ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = ["seriesId"])] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["seriesId"])] public async Task> GetEstimateToCompletion(int seriesId) { var userId = User.GetUserId(); diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 6ec781758..6c9be6c75 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -6,6 +6,7 @@ using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.ReadingLists; +using API.Entities.Enums; using API.Extensions; using API.Helpers; using API.Services; @@ -23,13 +24,15 @@ public class ReadingListController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly IReadingListService _readingListService; private readonly ILocalizationService _localizationService; + private readonly IReaderService _readerService; public ReadingListController(IUnitOfWork unitOfWork, IReadingListService readingListService, - ILocalizationService localizationService) + ILocalizationService localizationService, IReaderService readerService) { _unitOfWork = unitOfWork; _readingListService = readingListService; _localizationService = localizationService; + _readerService = readerService; } /// @@ -128,7 +131,7 @@ public class ReadingListController : BaseApiController } /// - /// Deletes a list item from the list. Will reorder all item positions afterwards + /// Deletes a list item from the list. Item orders will update as a result. /// /// /// @@ -452,26 +455,38 @@ public class ReadingListController : BaseApiController return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); } + /// - /// Returns a list of characters associated with the reading list + /// Returns a list of a given role associated with the reading list + /// + /// + /// PersonRole + /// + [HttpGet("people")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute, VaryByQueryKeys = ["readingListId", "role"])] + public ActionResult> GetPeopleByRoleForList(int readingListId, PersonRole role) + { + return Ok(_unitOfWork.ReadingListRepository.GetReadingListPeopleAsync(readingListId, role)); + } + + /// + /// Returns all people in given roles for a reading list /// /// /// - [HttpGet("characters")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute)] - public ActionResult> GetCharactersForList(int readingListId) + [HttpGet("all-people")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute, VaryByQueryKeys = ["readingListId"])] + public async Task>> GetAllPeopleForList(int readingListId) { - return Ok(_unitOfWork.ReadingListRepository.GetReadingListCharactersAsync(readingListId)); + return Ok(await _unitOfWork.ReadingListRepository.GetReadingListAllPeopleAsync(readingListId)); } - - /// /// Returns the next chapter within the reading list /// /// /// - /// Chapter Id for next item, -1 if nothing exists + /// Chapter ID for next item, -1 if nothing exists [HttpGet("next-chapter")] public async Task> GetNextChapter(int currentChapterId, int readingListId) { @@ -577,4 +592,26 @@ public class ReadingListController : BaseApiController return Ok(); } + + /// + /// Returns random information about a Reading List + /// + /// + /// + [HttpGet("info")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["readingListId"])] + public async Task> GetReadingListInfo(int readingListId) + { + var result = await _unitOfWork.ReadingListRepository.GetReadingListInfoAsync(readingListId); + + if (result == null) return Ok(null); + + var timeEstimate = _readerService.GetTimeEstimate(result.WordCount, result.Pages, result.IsAllEpub); + + result.MinHoursToRead = timeEstimate.MinHours; + result.AvgHoursToRead = timeEstimate.AvgHours; + result.MaxHoursToRead = timeEstimate.MaxHours; + + return Ok(result); + } } diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index dadfc74b7..f64e5e1b3 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -238,7 +238,8 @@ public class SeriesController : BaseApiController // Trigger a refresh when we are moving from a locked image to a non-locked needsRefreshMetadata = true; series.CoverImage = null; - series.CoverImageLocked = updateSeries.CoverImageLocked; + series.CoverImageLocked = false; + _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id); series.ResetColorScape(); } diff --git a/API/DTOs/ReadingLists/ReadingListCast.cs b/API/DTOs/ReadingLists/ReadingListCast.cs new file mode 100644 index 000000000..4532df7d5 --- /dev/null +++ b/API/DTOs/ReadingLists/ReadingListCast.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace API.DTOs.ReadingLists; + +public class ReadingListCast +{ + public ICollection Writers { get; set; } = []; + public ICollection CoverArtists { get; set; } = []; + public ICollection Publishers { get; set; } = []; + public ICollection Characters { get; set; } = []; + public ICollection Pencillers { get; set; } = []; + public ICollection Inkers { get; set; } = []; + public ICollection Imprints { get; set; } = []; + public ICollection Colorists { get; set; } = []; + public ICollection Letterers { get; set; } = []; + public ICollection Editors { get; set; } = []; + public ICollection Translators { get; set; } = []; + public ICollection Teams { get; set; } = []; + public ICollection Locations { get; set; } = []; +} diff --git a/API/DTOs/ReadingLists/ReadingListDto.cs b/API/DTOs/ReadingLists/ReadingListDto.cs index 139039bf5..6508e7bd4 100644 --- a/API/DTOs/ReadingLists/ReadingListDto.cs +++ b/API/DTOs/ReadingLists/ReadingListDto.cs @@ -1,4 +1,5 @@ using System; +using API.Entities.Enums; using API.Entities.Interfaces; namespace API.DTOs.ReadingLists; @@ -43,6 +44,10 @@ public class ReadingListDto : IHasCoverImage /// Maximum Month the Reading List starts /// public int EndingMonth { get; set; } + /// + /// The highest age rating from all Series within the reading list + /// + public required AgeRating AgeRating { get; set; } = AgeRating.Unknown; public void ResetColorScape() { diff --git a/API/DTOs/ReadingLists/ReadingListInfoDto.cs b/API/DTOs/ReadingLists/ReadingListInfoDto.cs new file mode 100644 index 000000000..bd95b9226 --- /dev/null +++ b/API/DTOs/ReadingLists/ReadingListInfoDto.cs @@ -0,0 +1,26 @@ +using API.DTOs.Reader; +using API.Entities.Interfaces; + +namespace API.DTOs.ReadingLists; + +public class ReadingListInfoDto : IHasReadTimeEstimate +{ + /// + /// Total Pages across all Reading List Items + /// + public int Pages { get; set; } + /// + /// Total Word count across all Reading List Items + /// + public long WordCount { get; set; } + /// + /// Are ALL Reading List Items epub + /// + public bool IsAllEpub { get; set; } + /// + public int MinHoursToRead { get; set; } + /// + public int MaxHoursToRead { get; set; } + /// + public float AvgHoursToRead { get; set; } +} diff --git a/API/DTOs/ReadingLists/ReadingListItemDto.cs b/API/DTOs/ReadingLists/ReadingListItemDto.cs index f1238d333..4fca5360c 100644 --- a/API/DTOs/ReadingLists/ReadingListItemDto.cs +++ b/API/DTOs/ReadingLists/ReadingListItemDto.cs @@ -25,7 +25,7 @@ public class ReadingListItemDto /// /// Release Date from Chapter /// - public DateTime ReleaseDate { get; set; } + public DateTime? ReleaseDate { get; set; } /// /// Used internally only /// @@ -33,7 +33,7 @@ public class ReadingListItemDto /// /// The last time a reading list item (underlying chapter) was read by current authenticated user /// - public DateTime LastReadingProgressUtc { get; set; } + public DateTime? LastReadingProgressUtc { get; set; } /// /// File size of underlying item /// diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 92b1b6be5..14987ae77 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -63,6 +63,13 @@ public class UserPreferencesDto /// [Required] public bool ShowScreenHints { get; set; } = true; + /// + /// Manga Reader Option: Allow Automatic Webtoon detection + /// + [Required] + public bool AllowAutomaticWebtoonReaderDetection { get; set; } + + /// /// Book Reader Option: Override extra Margin /// diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index babdada4a..4533a5dbf 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -133,6 +133,9 @@ public sealed class DataContext : IdentityDbContext() .Property(b => b.WantToReadSync) .HasDefaultValue(true); + builder.Entity() + .Property(b => b.AllowAutomaticWebtoonReaderDetection) + .HasDefaultValue(true); builder.Entity() .Property(b => b.AllowScrobbling) diff --git a/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.Designer.cs b/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.Designer.cs new file mode 100644 index 000000000..be3d5e3f9 --- /dev/null +++ b/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.Designer.cs @@ -0,0 +1,3403 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250328125012_AutomaticWebtoonReaderMode")] + partial class AutomaticWebtoonReaderMode + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.3"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("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("VolumeId"); + + b.ToTable("Chapter"); + }); + + 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.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("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .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("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("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.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("AverageScore") + .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.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("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + 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.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("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.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("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("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + 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("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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.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.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.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"); + + 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.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("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + 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("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.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + 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.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.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.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.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.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.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.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.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.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.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.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.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("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("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("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.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("People"); + + 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("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + 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.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.cs b/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.cs new file mode 100644 index 000000000..38b772811 --- /dev/null +++ b/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class AutomaticWebtoonReaderMode : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AllowAutomaticWebtoonReaderDetection", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AllowAutomaticWebtoonReaderDetection", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 3fc2ec3da..3e7d31e69 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.1"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.3"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -353,6 +353,11 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.Property("AniListScrobblingEnabled") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") @@ -911,24 +916,6 @@ namespace API.Data.Migrations b.ToTable("Chapter"); }); - modelBuilder.Entity("API.Entities.ChapterPeople", b => - { - b.Property("ChapterId") - .HasColumnType("INTEGER"); - - b.Property("PersonId") - .HasColumnType("INTEGER"); - - b.Property("Role") - .HasColumnType("INTEGER"); - - b.HasKey("ChapterId", "PersonId", "Role"); - - b.HasIndex("PersonId"); - - b.ToTable("ChapterPeople"); - }); - modelBuilder.Entity("API.Entities.CollectionTag", b => { b.Property("Id") @@ -1640,7 +1627,7 @@ namespace API.Data.Migrations b.ToTable("MetadataFieldMapping"); }); - modelBuilder.Entity("API.Entities.MetadataSettings", b => + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -1703,7 +1690,25 @@ namespace API.Data.Migrations b.ToTable("MetadataSettings"); }); - modelBuilder.Entity("API.Entities.Person", b => + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -1747,6 +1752,32 @@ namespace API.Data.Migrations b.ToTable("Person"); }); + 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.ReadingList", b => { b.Property("Id") @@ -2111,32 +2142,6 @@ namespace API.Data.Migrations b.ToTable("Series"); }); - modelBuilder.Entity("API.Entities.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.ServerSetting", b => { b.Property("Key") @@ -2804,25 +2809,6 @@ namespace API.Data.Migrations b.Navigation("Volume"); }); - modelBuilder.Entity("API.Entities.ChapterPeople", b => - { - b.HasOne("API.Entities.Chapter", "Chapter") - .WithMany("People") - .HasForeignKey("ChapterId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("API.Entities.Person", "Person") - .WithMany("ChapterPeople") - .HasForeignKey("PersonId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Chapter"); - - b.Navigation("Person"); - }); - modelBuilder.Entity("API.Entities.Device", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -2943,7 +2929,7 @@ namespace API.Data.Migrations modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => { - b.HasOne("API.Entities.MetadataSettings", "MetadataSettings") + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") .WithMany("FieldMappings") .HasForeignKey("MetadataSettingsId") .OnDelete(DeleteBehavior.Cascade) @@ -2952,6 +2938,44 @@ namespace API.Data.Migrations 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.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.ReadingList", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -3072,25 +3096,6 @@ namespace API.Data.Migrations b.Navigation("Library"); }); - modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => - { - b.HasOne("API.Entities.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.Volume", b => { b.HasOne("API.Entities.Series", "Series") @@ -3351,12 +3356,12 @@ namespace API.Data.Migrations b.Navigation("People"); }); - modelBuilder.Entity("API.Entities.MetadataSettings", b => + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => { b.Navigation("FieldMappings"); }); - modelBuilder.Entity("API.Entities.Person", b => + modelBuilder.Entity("API.Entities.Person.Person", b => { b.Navigation("ChapterPeople"); diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index 0fee41557..db66ecd79 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -1,10 +1,7 @@ -using System.Collections; -using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.DTOs; -using API.Entities; using API.Entities.Enums; using API.Entities.Person; using API.Extensions; @@ -31,15 +28,13 @@ public interface IPersonRepository Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role); Task RemoveAllPeopleNoLongerAssociated(); Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null); - Task GetCountAsync(); - Task GetCoverImageAsync(int personId); + Task GetCoverImageAsync(int personId); Task GetCoverImageByNameAsync(string name); Task> GetRolesForPersonByName(int personId, int userId); Task> GetAllWritersAndSeriesCount(int userId, UserParams userParams); Task GetPersonById(int personId); Task GetPersonDtoByName(string name, int userId); - Task GetPersonByName(string name); Task IsNameUnique(string name); Task> GetSeriesKnownFor(int personId); @@ -126,12 +121,8 @@ public class PersonRepository : IPersonRepository .ToListAsync(); } - public async Task GetCountAsync() - { - return await _context.Person.CountAsync(); - } - public async Task GetCoverImageAsync(int personId) + public async Task GetCoverImageAsync(int personId) { return await _context.Person .Where(c => c.Id == personId) @@ -139,7 +130,7 @@ public class PersonRepository : IPersonRepository .SingleOrDefaultAsync(); } - public async Task GetCoverImageByNameAsync(string name) + public async Task GetCoverImageByNameAsync(string name) { var normalized = name.ToNormalized(); return await _context.Person @@ -208,7 +199,7 @@ public class PersonRepository : IPersonRepository .FirstOrDefaultAsync(); } - public async Task GetPersonDtoByName(string name, int userId) + public async Task GetPersonDtoByName(string name, int userId) { var normalized = name.ToNormalized(); var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); @@ -220,11 +211,6 @@ public class PersonRepository : IPersonRepository .FirstOrDefaultAsync(); } - public async Task GetPersonByName(string name) - { - return await _context.Person.FirstOrDefaultAsync(p => p.NormalizedName == name.ToNormalized()); - } - public async Task IsNameUnique(string name) { return !(await _context.Person.AnyAsync(p => p.Name == name)); diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index 2296a03cc..83f2685f4 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -49,12 +49,14 @@ public interface IReadingListRepository Task> GetRandomCoverImagesAsync(int readingListId); Task> GetAllCoverImagesAsync(); Task ReadingListExists(string name); - IEnumerable GetReadingListCharactersAsync(int readingListId); + IEnumerable GetReadingListPeopleAsync(int readingListId, PersonRole role); + Task GetReadingListAllPeopleAsync(int readingListId); Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); Task RemoveReadingListsWithoutSeries(); Task GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items); Task> GetReadingListsByIds(IList ids, ReadingListIncludes includes = ReadingListIncludes.Items); Task> GetReadingListsBySeriesId(int seriesId, ReadingListIncludes includes = ReadingListIncludes.Items); + Task GetReadingListInfoAsync(int readingListId); } public class ReadingListRepository : IReadingListRepository @@ -121,12 +123,12 @@ public class ReadingListRepository : IReadingListRepository .AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized)); } - public IEnumerable GetReadingListCharactersAsync(int readingListId) + public IEnumerable GetReadingListPeopleAsync(int readingListId, PersonRole role) { return _context.ReadingListItem .Where(item => item.ReadingListId == readingListId) .SelectMany(item => item.Chapter.People) - .Where(p => p.Role == PersonRole.Character) + .Where(p => p.Role == role) .OrderBy(p => p.Person.NormalizedName) .Select(p => p.Person) .Distinct() @@ -134,6 +136,77 @@ public class ReadingListRepository : IReadingListRepository .AsEnumerable(); } + public async Task GetReadingListAllPeopleAsync(int readingListId) + { + var allPeople = await _context.ReadingListItem + .Where(item => item.ReadingListId == readingListId) + .SelectMany(item => item.Chapter.People) + .OrderBy(p => p.Person.NormalizedName) + .Select(p => new + { + Role = p.Role, + Person = _mapper.Map(p.Person) + }) + .Distinct() + .ToListAsync(); + + // Create the ReadingListCast object + var cast = new ReadingListCast(); + + // Group people by role and populate the appropriate collections + foreach (var personGroup in allPeople.GroupBy(p => p.Role)) + { + var people = personGroup.Select(pg => pg.Person).ToList(); + + switch (personGroup.Key) + { + case PersonRole.Writer: + cast.Writers = people; + break; + case PersonRole.CoverArtist: + cast.CoverArtists = people; + break; + case PersonRole.Publisher: + cast.Publishers = people; + break; + case PersonRole.Character: + cast.Characters = people; + break; + case PersonRole.Penciller: + cast.Pencillers = people; + break; + case PersonRole.Inker: + cast.Inkers = people; + break; + case PersonRole.Imprint: + cast.Imprints = people; + break; + case PersonRole.Colorist: + cast.Colorists = people; + break; + case PersonRole.Letterer: + cast.Letterers = people; + break; + case PersonRole.Editor: + cast.Editors = people; + break; + case PersonRole.Translator: + cast.Translators = people; + break; + case PersonRole.Team: + cast.Teams = people; + break; + case PersonRole.Location: + cast.Locations = people; + break; + case PersonRole.Other: + break; + } + } + + return cast; + } + public async Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat) { var extension = encodeFormat.GetExtension(); @@ -181,6 +254,33 @@ public class ReadingListRepository : IReadingListRepository .ToListAsync(); } + /// + /// Returns a Partial ReadingListInfoDto. The HourEstimate needs to be calculated outside the repo + /// + /// + /// + public async Task GetReadingListInfoAsync(int readingListId) + { + // Get sum of these across all ReadingListItems: long wordCount, int pageCount, bool isEpub (assume false if any ReadingListeItem.Series.Format is non-epub) + var readingList = await _context.ReadingList + .Where(rl => rl.Id == readingListId) + .Include(rl => rl.Items) + .ThenInclude(item => item.Series) + .Include(rl => rl.Items) + .ThenInclude(item => item.Volume) + .Include(rl => rl.Items) + .ThenInclude(item => item.Chapter) + .Select(rl => new ReadingListInfoDto() + { + WordCount = rl.Items.Sum(item => item.Chapter.WordCount), + Pages = rl.Items.Sum(item => item.Chapter.Pages), + IsAllEpub = rl.Items.All(item => item.Series.Format == MangaFormat.Epub), + }) + .FirstOrDefaultAsync(); + + return readingList; + } + public void Remove(ReadingListItem item) { diff --git a/API/Data/Repositories/ScrobbleEventRepository.cs b/API/Data/Repositories/ScrobbleEventRepository.cs index 177ddfb96..c5f30c2ec 100644 --- a/API/Data/Repositories/ScrobbleEventRepository.cs +++ b/API/Data/Repositories/ScrobbleEventRepository.cs @@ -167,11 +167,11 @@ public class ScrobbleRepository : IScrobbleRepository var query = _context.ScrobbleEvent .Where(e => e.AppUserId == userId) .Include(e => e.Series) - .SortBy(filter.Field, filter.IsDescending) .WhereIf(!string.IsNullOrEmpty(filter.Query), s => EF.Functions.Like(s.Series.Name, $"%{filter.Query}%") ) .WhereIf(!filter.IncludeReviews, e => e.ScrobbleEventType != ScrobbleEventType.Review) + .SortBy(filter.Field, filter.IsDescending) .AsSplitQuery() .ProjectTo(_mapper.ConfigurationProvider); diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index 2489c6688..b728e84e5 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -54,6 +54,10 @@ public class AppUserPreferences /// Manga Reader Option: Should swiping trigger pagination /// public bool SwipeToPaginate { get; set; } + /// + /// Manga Reader Option: Allow Automatic Webtoon detection + /// + public bool AllowAutomaticWebtoonReaderDetection { get; set; } #endregion diff --git a/API/Entities/Scrobble/ScrobbleEventSortField.cs b/API/Entities/Scrobble/ScrobbleEventSortField.cs index 729ac7fbe..51b3a2146 100644 --- a/API/Entities/Scrobble/ScrobbleEventSortField.cs +++ b/API/Entities/Scrobble/ScrobbleEventSortField.cs @@ -7,5 +7,6 @@ public enum ScrobbleEventSortField LastModified = 2, Type= 3, Series = 4, - IsProcessed = 5 + IsProcessed = 5, + ScrobbleEventFilter = 6 } diff --git a/API/Extensions/QueryExtensions/QueryableExtensions.cs b/API/Extensions/QueryExtensions/QueryableExtensions.cs index 8c8640be4..a2db1dde7 100644 --- a/API/Extensions/QueryExtensions/QueryableExtensions.cs +++ b/API/Extensions/QueryExtensions/QueryableExtensions.cs @@ -255,6 +255,7 @@ public static class QueryableExtensions ScrobbleEventSortField.Type => query.OrderByDescending(s => s.ScrobbleEventType), ScrobbleEventSortField.Series => query.OrderByDescending(s => s.Series.NormalizedName), ScrobbleEventSortField.IsProcessed => query.OrderByDescending(s => s.IsProcessed), + ScrobbleEventSortField.ScrobbleEventFilter => query.OrderByDescending(s => s.ScrobbleEventType), _ => query }; } @@ -267,6 +268,7 @@ public static class QueryableExtensions ScrobbleEventSortField.Type => query.OrderBy(s => s.ScrobbleEventType), ScrobbleEventSortField.Series => query.OrderBy(s => s.Series.NormalizedName), ScrobbleEventSortField.IsProcessed => query.OrderBy(s => s.IsProcessed), + ScrobbleEventSortField.ScrobbleEventFilter => query.OrderBy(s => s.ScrobbleEventType), _ => query }; } diff --git a/API/Helpers/StringHelper.cs b/API/Helpers/StringHelper.cs index bd063b2a7..0a20910c5 100644 --- a/API/Helpers/StringHelper.cs +++ b/API/Helpers/StringHelper.cs @@ -14,6 +14,8 @@ public static partial class StringHelper private static partial Regex BrMultipleRegex(); [GeneratedRegex(@"\s+")] private static partial Regex WhiteSpaceRegex(); + [GeneratedRegex("&#64;")] + private static partial Regex HtmlEncodedAtSymbolRegex(); #endregion /// @@ -52,4 +54,16 @@ public static partial class StringHelper return SourceRegex().Replace(description, string.Empty).Trim(); } + + /// + /// Replaces some HTML encoded characters in urls with the proper symbol. This is common in People Description's + /// + /// + /// + public static string? CorrectUrls(string? description) + { + if (string.IsNullOrEmpty(description)) return description; + + return HtmlEncodedAtSymbolRegex().Replace(description, "@"); + } } diff --git a/API/Services/MediaConversionService.cs b/API/Services/MediaConversionService.cs index 9f6b18374..fc3e5f318 100644 --- a/API/Services/MediaConversionService.cs +++ b/API/Services/MediaConversionService.cs @@ -222,6 +222,10 @@ public class MediaConversionService : IMediaConversionService { if (string.IsNullOrEmpty(series.CoverImage)) continue; series.CoverImage = series.GetCoverImage(); + if (series.CoverImage == null) + { + _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id); + } _unitOfWork.SeriesRepository.Update(series); await _unitOfWork.CommitAsync(); } diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index c14f4409a..e0e86f4dc 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -199,6 +199,10 @@ public class MetadataService : IMetadataService series.Volumes ??= []; series.CoverImage = series.GetCoverImage(); + if (series.CoverImage == null) + { + _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id); + } _imageService.UpdateColorScape(series); diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index 2183bc43e..ae0af5e81 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -76,7 +76,7 @@ public class ExternalMetadataService : IExternalMetadataService }; // Allow 50 requests per 24 hours private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(24), false); - static bool IsRomanCharacters(string input) => Regex.IsMatch(input, @"^[\p{IsBasicLatin}\p{IsLatin-1Supplement}]+$"); + private static bool IsRomanCharacters(string input) => Regex.IsMatch(input, @"^[\p{IsBasicLatin}\p{IsLatin-1Supplement}]+$"); public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger logger, IMapper mapper, ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService) @@ -115,18 +115,24 @@ public class ExternalMetadataService : IExternalMetadataService // Find all Series that are eligible and limit var ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25, false); if (ids.Count == 0) return; + ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25, true); - _logger.LogInformation("[Kavita+ Data Refresh] Started Refreshing {Count} series data from Kavita+", ids.Count); + _logger.LogInformation("[Kavita+ Data Refresh] Started Refreshing {Count} series data from Kavita+: {Ids}", ids.Count, string.Join(',', ids)); var count = 0; + var successfulMatches = new List(); var libTypes = await _unitOfWork.LibraryRepository.GetLibraryTypesBySeriesIdsAsync(ids); foreach (var seriesId in ids) { var libraryType = libTypes[seriesId]; var success = await FetchSeriesMetadata(seriesId, libraryType); - if (success) count++; + if (success) + { + count++; + successfulMatches.Add(seriesId); + } await Task.Delay(6000); // Currently AL is degraded and has 30 requests/min, give a little padding since this is a background request } - _logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} series data from Kavita+", count); + _logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} / {Total} series data from Kavita+: {Ids}", count, ids.Count, string.Join(',', successfulMatches)); } @@ -146,7 +152,7 @@ public class ExternalMetadataService : IExternalMetadataService if (!RateLimiter.TryAcquire(string.Empty)) { // Request not allowed due to rate limit - _logger.LogDebug("Rate Limit hit for Kavita+ prefetch"); + _logger.LogInformation("Rate Limit hit for Kavita+ prefetch"); return false; } @@ -731,7 +737,7 @@ public class ExternalMetadataService : IExternalMetadataService { Name = w.Name, AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListCharacterWebsite), - Description = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description)), + Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), }) .Concat(series.Metadata.People .Where(p => p.Role == PersonRole.Character) @@ -743,7 +749,9 @@ public class ExternalMetadataService : IExternalMetadataService .ToList(); if (characters.Count == 0) return false; + await SeriesService.HandlePeopleUpdateAsync(series.Metadata, characters, PersonRole.Character, _unitOfWork); + foreach (var spPerson in series.Metadata.People.Where(p => p.Role == PersonRole.Character)) { // Set a sort order based on their role @@ -810,7 +818,7 @@ public class ExternalMetadataService : IExternalMetadataService { Name = w.Name, AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListStaffWebsite), - Description = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description)), + Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), }) .Concat(series.Metadata.People .Where(p => p.Role == PersonRole.CoverArtist) @@ -867,7 +875,7 @@ public class ExternalMetadataService : IExternalMetadataService { Name = w.Name, AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListStaffWebsite), - Description = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description)), + Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), }) .Concat(series.Metadata.People .Where(p => p.Role == PersonRole.Writer) diff --git a/API/Services/Tasks/Metadata/CoverDbService.cs b/API/Services/Tasks/Metadata/CoverDbService.cs index 59214e116..e3fd51296 100644 --- a/API/Services/Tasks/Metadata/CoverDbService.cs +++ b/API/Services/Tasks/Metadata/CoverDbService.cs @@ -552,14 +552,22 @@ public class CoverDbService : ICoverDbService series.CoverImage = filePath; series.CoverImageLocked = true; + if (series.CoverImage == null) + { + _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null"); + } _imageService.UpdateColorScape(series); _unitOfWork.SeriesRepository.Update(series); } } else { - series.CoverImage = string.Empty; + series.CoverImage = null; series.CoverImageLocked = false; + if (series.CoverImage == null) + { + _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null"); + } _imageService.UpdateColorScape(series); _unitOfWork.SeriesRepository.Update(series); } diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index eb3391adc..123b610ff 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -278,7 +278,8 @@ public partial class VersionUpdaterService : IVersionUpdaterService { // Attempt to fetch from cache var cachedReleases = await TryGetCachedReleases(); - if (cachedReleases != null) + // If there is a cached release and the current version is within it, use it, otherwise regenerate + if (cachedReleases != null && cachedReleases.Any(r => IsVersionEqual(r.UpdateVersion, BuildInfo.Version.ToString()))) { if (count > 0) { @@ -338,6 +339,29 @@ public partial class VersionUpdaterService : IVersionUpdaterService return updateDtos; } + /// + /// Compares 2 versions and ensures that the minor is always there + /// + /// + /// + /// + private static bool IsVersionEqual(string v1, string v2) + { + var versionParts = v1.Split('.'); + if (versionParts.Length < 4) + { + v1 += ".0"; // Append missing parts + } + + versionParts = v2.Split('.'); + if (versionParts.Length < 4) + { + v2 += ".0"; // Append missing parts + } + + return string.Equals(v2, v2, StringComparison.OrdinalIgnoreCase); + } + private async Task?> TryGetCachedReleases() { if (!File.Exists(_cacheFilePath)) return null; @@ -370,7 +394,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService { try { - var json = System.Text.Json.JsonSerializer.Serialize(updates, JsonOptions); + var json = JsonSerializer.Serialize(updates, JsonOptions); await File.WriteAllTextAsync(_cacheFilePath, json); } catch (Exception ex) diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 7e785330d..5ef3f73cb 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -542,6 +542,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.3.tgz", "integrity": "sha512-ePh/7A6eEDAyfVn8QgLcAvrxhXBAf6mTqB/3+HwQeXLaka1gtN6xvZ6cjLEegP4s6kcYGhdfdLwzCcy0kjsY5g==", + "dev": true, "dependencies": { "@babel/core": "7.26.9", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -569,6 +570,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "dev": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -583,6 +585,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "dev": true, "engines": { "node": ">= 14.16.0" }, @@ -4904,7 +4907,8 @@ "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true }, "node_modules/cosmiconfig": { "version": "8.3.6", @@ -5351,6 +5355,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -5360,6 +5365,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -8178,7 +8184,8 @@ "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true }, "node_modules/replace-in-file": { "version": "7.1.0", @@ -8399,7 +8406,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true + "dev": true }, "node_modules/sass": { "version": "1.85.0", @@ -8464,6 +8471,7 @@ "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -9088,6 +9096,7 @@ "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index 27241ba5e..1dd5731e5 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -1,12 +1,11 @@ - -import { LayoutMode } from 'src/app/manga-reader/_models/layout-mode'; -import { BookPageLayoutMode } from '../readers/book-page-layout-mode'; -import { PageLayoutMode } from '../page-layout-mode'; -import { PageSplitOption } from './page-split-option'; -import { ReaderMode } from './reader-mode'; -import { ReadingDirection } from './reading-direction'; -import { ScalingOption } from './scaling-option'; -import { SiteTheme } from './site-theme'; +import {LayoutMode} from 'src/app/manga-reader/_models/layout-mode'; +import {BookPageLayoutMode} from '../readers/book-page-layout-mode'; +import {PageLayoutMode} from '../page-layout-mode'; +import {PageSplitOption} from './page-split-option'; +import {ReaderMode} from './reader-mode'; +import {ReadingDirection} from './reading-direction'; +import {ScalingOption} from './scaling-option'; +import {SiteTheme} from './site-theme'; import {WritingStyle} from "./writing-style"; import {PdfTheme} from "./pdf-theme"; import {PdfScrollMode} from "./pdf-scroll-mode"; @@ -14,48 +13,49 @@ import {PdfLayoutMode} from "./pdf-layout-mode"; import {PdfSpreadMode} from "./pdf-spread-mode"; export interface Preferences { - // Manga Reader - readingDirection: ReadingDirection; - scalingOption: ScalingOption; - pageSplitOption: PageSplitOption; - readerMode: ReaderMode; - autoCloseMenu: boolean; - layoutMode: LayoutMode; - backgroundColor: string; - showScreenHints: boolean; - emulateBook: boolean; - swipeToPaginate: boolean; + // Manga Reader + readingDirection: ReadingDirection; + scalingOption: ScalingOption; + pageSplitOption: PageSplitOption; + readerMode: ReaderMode; + autoCloseMenu: boolean; + layoutMode: LayoutMode; + backgroundColor: string; + showScreenHints: boolean; + emulateBook: boolean; + swipeToPaginate: boolean; + allowAutomaticWebtoonReaderDetection: boolean; - // Book Reader - bookReaderMargin: number; - bookReaderLineSpacing: number; - bookReaderFontSize: number; - bookReaderFontFamily: string; - bookReaderTapToPaginate: boolean; - bookReaderReadingDirection: ReadingDirection; - bookReaderWritingStyle: WritingStyle; - bookReaderThemeName: string; - bookReaderLayoutMode: BookPageLayoutMode; - bookReaderImmersiveMode: boolean; + // Book Reader + bookReaderMargin: number; + bookReaderLineSpacing: number; + bookReaderFontSize: number; + bookReaderFontFamily: string; + bookReaderTapToPaginate: boolean; + bookReaderReadingDirection: ReadingDirection; + bookReaderWritingStyle: WritingStyle; + bookReaderThemeName: string; + bookReaderLayoutMode: BookPageLayoutMode; + bookReaderImmersiveMode: boolean; - // PDF Reader - pdfTheme: PdfTheme; - pdfScrollMode: PdfScrollMode; - pdfSpreadMode: PdfSpreadMode; + // PDF Reader + pdfTheme: PdfTheme; + pdfScrollMode: PdfScrollMode; + pdfSpreadMode: PdfSpreadMode; - // Global - theme: SiteTheme; - globalPageLayoutMode: PageLayoutMode; - blurUnreadSummaries: boolean; - promptForDownloadSize: boolean; - noTransitions: boolean; - collapseSeriesRelationships: boolean; - shareReviews: boolean; - locale: string; + // Global + theme: SiteTheme; + globalPageLayoutMode: PageLayoutMode; + blurUnreadSummaries: boolean; + promptForDownloadSize: boolean; + noTransitions: boolean; + collapseSeriesRelationships: boolean; + shareReviews: boolean; + locale: string; - // Kavita+ - aniListScrobblingEnabled: boolean; - wantToReadSync: boolean; + // Kavita+ + aniListScrobblingEnabled: boolean; + wantToReadSync: boolean; } export const readingDirections = [{text: 'left-to-right', value: ReadingDirection.LeftToRight}, {text: 'right-to-left', value: ReadingDirection.RightToLeft}]; diff --git a/UI/Web/src/app/_models/reading-list.ts b/UI/Web/src/app/_models/reading-list.ts index d5b115ad0..646360153 100644 --- a/UI/Web/src/app/_models/reading-list.ts +++ b/UI/Web/src/app/_models/reading-list.ts @@ -1,6 +1,9 @@ -import { LibraryType } from "./library/library"; -import { MangaFormat } from "./manga-format"; +import {LibraryType} from "./library/library"; +import {MangaFormat} from "./manga-format"; import {IHasCover} from "./common/i-has-cover"; +import {AgeRating} from "./metadata/age-rating"; +import {IHasReadingTime} from "./common/i-has-reading-time"; +import {IHasCast} from "./common/i-has-cast"; export interface ReadingListItem { pagesRead: number; @@ -30,13 +33,25 @@ export interface ReadingList extends IHasCover { items: Array; /** * If this is empty or null, the cover image isn't set. Do not use this externally. - */ - coverImage?: string; - primaryColor: string; - secondaryColor: string; - startingYear: number; - startingMonth: number; - endingYear: number; - endingMonth: number; + */ + coverImage?: string; + primaryColor: string; + secondaryColor: string; + startingYear: number; + startingMonth: number; + endingYear: number; + endingMonth: number; itemCount: number; + ageRating: AgeRating; } + +export interface ReadingListInfo extends IHasReadingTime, IHasReadingTime { + pages: number; + wordCount: number; + isAllEpub: boolean; + minHoursToRead: number; + maxHoursToRead: number; + avgHoursToRead: number; +} + +export interface ReadingListCast extends IHasCast {} diff --git a/UI/Web/src/app/_models/scrobbling/scrobble-event-filter.ts b/UI/Web/src/app/_models/scrobbling/scrobble-event-filter.ts index 102cf89d1..c0ea95d64 100644 --- a/UI/Web/src/app/_models/scrobbling/scrobble-event-filter.ts +++ b/UI/Web/src/app/_models/scrobbling/scrobble-event-filter.ts @@ -4,7 +4,8 @@ export enum ScrobbleEventSortField { LastModified = 2, Type= 3, Series = 4, - IsProcessed = 5 + IsProcessed = 5, + ScrobbleEvent = 6 } export interface ScrobbleEventFilter { diff --git a/UI/Web/src/app/_models/series-detail/hour-estimate-range.ts b/UI/Web/src/app/_models/series-detail/hour-estimate-range.ts index f94ac569b..805a71178 100644 --- a/UI/Web/src/app/_models/series-detail/hour-estimate-range.ts +++ b/UI/Web/src/app/_models/series-detail/hour-estimate-range.ts @@ -1,6 +1,5 @@ -export interface HourEstimateRange{ +export interface HourEstimateRange { minHours: number; maxHours: number; avgHours: number; - //hasProgress: boolean; -} \ No newline at end of file +} diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 025e5cf29..9941cd005 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -1,19 +1,19 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; +import {HttpClient} from '@angular/common/http'; import {DestroyRef, Inject, inject, Injectable} from '@angular/core'; import {DOCUMENT, Location} from '@angular/common'; -import { Router } from '@angular/router'; -import { environment } from 'src/environments/environment'; -import { ChapterInfo } from '../manga-reader/_models/chapter-info'; -import { Chapter } from '../_models/chapter'; -import { HourEstimateRange } from '../_models/series-detail/hour-estimate-range'; -import { MangaFormat } from '../_models/manga-format'; -import { BookmarkInfo } from '../_models/manga-reader/bookmark-info'; -import { PageBookmark } from '../_models/readers/page-bookmark'; -import { ProgressBookmark } from '../_models/readers/progress-bookmark'; -import { FileDimension } from '../manga-reader/_models/file-dimension'; +import {Router} from '@angular/router'; +import {environment} from 'src/environments/environment'; +import {ChapterInfo} from '../manga-reader/_models/chapter-info'; +import {Chapter} from '../_models/chapter'; +import {HourEstimateRange} from '../_models/series-detail/hour-estimate-range'; +import {MangaFormat} from '../_models/manga-format'; +import {BookmarkInfo} from '../_models/manga-reader/bookmark-info'; +import {PageBookmark} from '../_models/readers/page-bookmark'; +import {ProgressBookmark} from '../_models/readers/progress-bookmark'; +import {FileDimension} from '../manga-reader/_models/file-dimension'; import screenfull from 'screenfull'; -import { TextResonse } from '../_types/text-response'; -import { AccountService } from './account.service'; +import {TextResonse} from '../_types/text-response'; +import {AccountService} from './account.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {PersonalToC} from "../_models/readers/personal-toc"; import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; @@ -23,7 +23,6 @@ import {Volume} from "../_models/volume"; import {UtilityService} from "../shared/_services/utility.service"; import {translate} from "@jsverse/transloco"; import {ToastrService} from "ngx-toastr"; -import {getIosVersion, isSafari, Version} from "../_helpers/browser"; export const CHAPTER_ID_DOESNT_EXIST = -1; @@ -112,7 +111,6 @@ export class ReaderService { return this.httpClient.post(this.baseUrl + 'reader/all-bookmarks', filter); } - getBookmarks(chapterId: number) { return this.httpClient.get(this.baseUrl + 'reader/chapter-bookmarks?chapterId=' + chapterId); } diff --git a/UI/Web/src/app/_services/reading-list.service.ts b/UI/Web/src/app/_services/reading-list.service.ts index 04d97060f..088263a33 100644 --- a/UI/Web/src/app/_services/reading-list.service.ts +++ b/UI/Web/src/app/_services/reading-list.service.ts @@ -1,13 +1,13 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { map } from 'rxjs/operators'; -import { environment } from 'src/environments/environment'; -import { UtilityService } from '../shared/_services/utility.service'; -import { Person } from '../_models/metadata/person'; -import { PaginatedResult } from '../_models/pagination'; -import { ReadingList, ReadingListItem } from '../_models/reading-list'; -import { CblImportSummary } from '../_models/reading-list/cbl/cbl-import-summary'; -import { TextResonse } from '../_types/text-response'; +import {HttpClient, HttpParams} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {map} from 'rxjs/operators'; +import {environment} from 'src/environments/environment'; +import {UtilityService} from '../shared/_services/utility.service'; +import {Person, PersonRole} from '../_models/metadata/person'; +import {PaginatedResult} from '../_models/pagination'; +import {ReadingList, ReadingListCast, ReadingListInfo, ReadingListItem} from '../_models/reading-list'; +import {CblImportSummary} from '../_models/reading-list/cbl/cbl-import-summary'; +import {TextResonse} from '../_types/text-response'; import {Action, ActionItem} from './action-factory.service'; @Injectable({ @@ -20,7 +20,7 @@ export class ReadingListService { constructor(private httpClient: HttpClient, private utilityService: UtilityService) { } getReadingList(readingListId: number) { - return this.httpClient.get(this.baseUrl + 'readinglist?readingListId=' + readingListId); + return this.httpClient.get(this.baseUrl + 'readinglist?readingListId=' + readingListId); } getReadingLists(includePromoted: boolean = true, sortByLastModified: boolean = false, pageNum?: number, itemsPerPage?: number) { @@ -114,10 +114,20 @@ export class ReadingListService { return this.httpClient.post(this.baseUrl + `cbl/import?dryRun=${dryRun}&useComicVineMatching=${useComicVineMatching}`, form); } - getCharacters(readingListId: number) { - return this.httpClient.get>(this.baseUrl + 'readinglist/characters?readingListId=' + readingListId); + getPeople(readingListId: number, role: PersonRole) { + return this.httpClient.get>(this.baseUrl + `readinglist/people?readingListId=${readingListId}&role=${role}`); } + getAllPeople(readingListId: number) { + return this.httpClient.get(this.baseUrl + `readinglist/all-people?readingListId=${readingListId}`); + } + + + getReadingListInfo(readingListId: number) { + return this.httpClient.get(this.baseUrl + `readinglist/info?readingListId=${readingListId}`); + } + + promoteMultipleReadingLists(listIds: Array, promoted: boolean) { return this.httpClient.post(this.baseUrl + 'readinglist/promote-multiple', {readingListIds: listIds, promoted}, TextResonse); } diff --git a/UI/Web/src/app/_services/version.service.ts b/UI/Web/src/app/_services/version.service.ts index 169fc11c5..26c33bc01 100644 --- a/UI/Web/src/app/_services/version.service.ts +++ b/UI/Web/src/app/_services/version.service.ts @@ -6,6 +6,7 @@ import {filter, take} from "rxjs/operators"; import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; import {NewUpdateModalComponent} from "../announcements/_components/new-update-modal/new-update-modal.component"; import {OutOfDateModalComponent} from "../announcements/_components/out-of-date-modal/out-of-date-modal.component"; +import {Router} from "@angular/router"; @Injectable({ providedIn: 'root' @@ -15,6 +16,7 @@ export class VersionService implements OnDestroy{ private readonly serverService = inject(ServerService); private readonly accountService = inject(AccountService); private readonly modalService = inject(NgbModal); + private readonly router = inject(Router); public static readonly SERVER_VERSION_KEY = 'kavita--version'; public static readonly CLIENT_REFRESH_KEY = 'kavita--client-refresh-last-shown'; @@ -29,15 +31,23 @@ export class VersionService implements OnDestroy{ // Check intervals private readonly VERSION_CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes private readonly OUT_OF_DATE_CHECK_INTERVAL = this.VERSION_CHECK_INTERVAL; // 2 * 60 * 60 * 1000; // 2 hours - private readonly OUT_Of_BAND_AMOUNT = 2; // How many releases before we show "You're X releases out of date" + // Routes where version update modals should not be shown + private readonly EXCLUDED_ROUTES = [ + '/manga/', + '/book/', + '/pdf/', + '/reader/' + ]; + private versionCheckSubscription?: Subscription; private outOfDateCheckSubscription?: Subscription; private modalOpen = false; constructor() { + this.startInitialVersionCheck(); this.startVersionCheck(); this.startOutOfDateCheck(); } @@ -47,6 +57,26 @@ export class VersionService implements OnDestroy{ this.outOfDateCheckSubscription?.unsubscribe(); } + /** + * Initial version check to ensure localStorage is populated on first load + */ + private startInitialVersionCheck(): void { + this.accountService.currentUser$ + .pipe( + filter(user => !!user), + take(1), + switchMap(user => this.serverService.getVersion(user!.apiKey)) + ) + .subscribe(serverVersion => { + const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY); + + // Always update localStorage on first load + localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion); + + console.log('Initial version check - Server version:', serverVersion, 'Cached version:', cachedVersion); + }); + } + /** * Periodic check for server version to detect client refreshes and new updates */ @@ -76,12 +106,26 @@ export class VersionService implements OnDestroy{ .subscribe(versionsOutOfDate => this.handleOutOfDateNotification(versionsOutOfDate)); } + /** + * Checks if the current route is in the excluded routes list + */ + private isExcludedRoute(): boolean { + const currentUrl = this.router.url; + return this.EXCLUDED_ROUTES.some(route => currentUrl.includes(route)); + } + /** * Handles the version check response to determine if client refresh or new update notification is needed */ private handleVersionUpdate(serverVersion: string) { if (this.modalOpen) return; + // Validate if we are on a reader route and if so, suppress + if (this.isExcludedRoute()) { + console.log('Version update blocked due to user reading'); + return; + } + const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY); console.log('Server version:', serverVersion, 'Cached version:', cachedVersion); diff --git a/UI/Web/src/app/_single-module/details-tab/details-tab.component.html b/UI/Web/src/app/_single-module/details-tab/details-tab.component.html index 711141195..6f307a55d 100644 --- a/UI/Web/src/app/_single-module/details-tab/details-tab.component.html +++ b/UI/Web/src/app/_single-module/details-tab/details-tab.component.html @@ -28,12 +28,24 @@ } -
-

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

-
- {{format | mangaFormat }} + @if (ageRating) { +
+

{{t('age-rating-title')}}

+
+ +
-
+ } + + @if (format) { +
+

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

+
+ {{format | mangaFormat }} +
+
+ } + diff --git a/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts b/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts index aba7a9162..72d1df227 100644 --- a/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts +++ b/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts @@ -20,6 +20,8 @@ import {MangaFormatPipe} from "../../_pipes/manga-format.pipe"; import {LanguageNamePipe} from "../../_pipes/language-name.pipe"; import {AsyncPipe} from "@angular/common"; import {SafeUrlPipe} from "../../_pipes/safe-url.pipe"; +import {AgeRating} from "../../_models/metadata/age-rating"; +import {AgeRatingImageComponent} from "../age-rating-image/age-rating-image.component"; @Component({ selector: 'app-details-tab', @@ -34,7 +36,8 @@ import {SafeUrlPipe} from "../../_pipes/safe-url.pipe"; MangaFormatPipe, LanguageNamePipe, AsyncPipe, - SafeUrlPipe + SafeUrlPipe, + AgeRatingImageComponent ], templateUrl: './details-tab.component.html', styleUrl: './details-tab.component.scss', @@ -47,11 +50,13 @@ export class DetailsTabComponent { protected readonly PersonRole = PersonRole; protected readonly FilterField = FilterField; + protected readonly MangaFormat = MangaFormat; @Input({required: true}) metadata!: IHasCast; @Input() readingTime: IHasReadingTime | undefined; + @Input() ageRating: AgeRating | undefined; @Input() language: string | undefined; - @Input() format: MangaFormat = MangaFormat.UNKNOWN; + @Input() format: MangaFormat | undefined; @Input() releaseYear: number | undefined; @Input() genres: Array = []; @Input() tags: Array = []; @@ -62,6 +67,4 @@ export class DetailsTabComponent { if (queryParamName === FilterField.None) return; this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter}`).subscribe(); } - - protected readonly MangaFormat = MangaFormat; } diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html index f0e59c482..15d0c5239 100644 --- a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html +++ b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html @@ -8,7 +8,7 @@
{{item.series.name}} ({{item.matchRating | translocoPercent}})
-
+
@for(synm of item.series.synonyms; track synm; let last = $last) { {{synm}} @if (!last) { diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts index 77cf17525..7dd953ad8 100644 --- a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts +++ b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts @@ -8,9 +8,7 @@ import { Output } from '@angular/core'; import {ImageComponent} from "../../shared/image/image.component"; -import {SeriesFormatComponent} from "../../shared/series-format/series-format.component"; import {ExternalSeriesMatch} from "../../_models/series-detail/external-series-match"; -import {PercentPipe} from "@angular/common"; import {TranslocoPercentPipe} from "@jsverse/transloco-locale"; import {ReadMoreComponent} from "../../shared/read-more/read-more.component"; import {TranslocoDirective} from "@jsverse/transloco"; @@ -18,18 +16,18 @@ import {PlusMediaFormatPipe} from "../../_pipes/plus-media-format.pipe"; import {LoadingComponent} from "../../shared/loading/loading.component"; @Component({ - selector: 'app-match-series-result-item', - imports: [ - ImageComponent, - TranslocoPercentPipe, - ReadMoreComponent, - TranslocoDirective, - PlusMediaFormatPipe, - LoadingComponent - ], - templateUrl: './match-series-result-item.component.html', - styleUrl: './match-series-result-item.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'app-match-series-result-item', + imports: [ + ImageComponent, + TranslocoPercentPipe, + ReadMoreComponent, + TranslocoDirective, + PlusMediaFormatPipe, + LoadingComponent + ], + templateUrl: './match-series-result-item.component.html', + styleUrl: './match-series-result-item.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush }) export class MatchSeriesResultItemComponent { diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html index f80978a19..652433940 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html @@ -35,6 +35,7 @@ [count]="pageInfo.totalElements" [offset]="pageInfo.pageNumber" [limit]="pageInfo.size" + [sorts]="[{prop: 'lastModifiedUtc', dir: 'desc'}]" > diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts index b3c2991d6..0b57de246 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts @@ -10,7 +10,7 @@ import {debounceTime, take} from "rxjs/operators"; import {PaginatedResult} from "../../_models/pagination"; import {SortEvent} from "../table/_directives/sortable-header.directive"; import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; -import {TranslocoModule} from "@jsverse/transloco"; +import {translate, TranslocoModule} from "@jsverse/transloco"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; import {TranslocoLocaleModule} from "@jsverse/transloco-locale"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; @@ -18,6 +18,7 @@ import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapt import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; import {AsyncPipe} from "@angular/common"; import {AccountService} from "../../_services/account.service"; +import {ToastrService} from "ngx-toastr"; export interface DataTablePage { pageNumber: number, @@ -44,6 +45,7 @@ export class UserScrobbleHistoryComponent implements OnInit { private readonly scrobblingService = inject(ScrobblingService); private readonly cdRef = inject(ChangeDetectorRef); private readonly destroyRef = inject(DestroyRef); + private readonly toastr = inject(ToastrService); protected readonly accountService = inject(AccountService); @@ -60,6 +62,10 @@ export class UserScrobbleHistoryComponent implements OnInit { totalElements: 0, totalPages: 0 } + private currentSort: SortEvent = { + column: 'lastModifiedUtc', + direction: 'desc' + }; ngOnInit() { @@ -73,26 +79,26 @@ export class UserScrobbleHistoryComponent implements OnInit { this.formGroup.get('filter')?.valueChanges.pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(query => { this.loadPage(); - }) + }); + + this.loadPage(this.currentSort); } onPageChange(pageInfo: any) { this.pageInfo.pageNumber = pageInfo.offset; this.cdRef.markForCheck(); - this.loadPage(); + this.loadPage(this.currentSort); } updateSort(data: any) { - this.loadPage({column: data.column.prop, direction: data.newValue}); + this.currentSort = { + column: data.column.prop, + direction: data.newValue + }; } loadPage(sortEvent?: SortEvent) { - if (sortEvent && this.pageInfo) { - this.pageInfo.pageNumber = 1; - this.cdRef.markForCheck(); - } - const page = (this.pageInfo?.pageNumber || 0) + 1; const pageSize = this.pageInfo?.size || 0; const isDescending = sortEvent?.direction === 'desc'; @@ -102,7 +108,6 @@ export class UserScrobbleHistoryComponent implements OnInit { this.isLoading = true; this.cdRef.markForCheck(); - // BUG: Table should be sorted by lastModifiedUtc by default this.scrobblingService.getScrobbleEvents({query, field, isDescending}, page, pageSize) .pipe(take(1)) .subscribe((result: PaginatedResult) => { @@ -122,13 +127,14 @@ export class UserScrobbleHistoryComponent implements OnInit { case 'isProcessed': return ScrobbleEventSortField.IsProcessed; case 'lastModifiedUtc': return ScrobbleEventSortField.LastModified; case 'seriesName': return ScrobbleEventSortField.Series; + case 'scrobbleEventType': return ScrobbleEventSortField.ScrobbleEvent; } return ScrobbleEventSortField.None; } generateScrobbleEvents() { this.scrobblingService.triggerScrobbleEventGeneration().subscribe(_ => { - + this.toastr.info(translate('toasts.scrobble-gen-init')) }); } } diff --git a/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.html b/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.html index 7b1d9659f..78724272c 100644 --- a/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.html +++ b/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.html @@ -34,12 +34,12 @@ - + {{t('created-header')}} - {{item.createdUtc | utcToLocalTime | defaultValue }} + {{item.created | utcToLocalTime | defaultValue }} @@ -57,9 +57,9 @@ {{t('edit-header')}} - diff --git a/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.ts b/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.ts index d6ef35279..7efe74996 100644 --- a/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.ts +++ b/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.ts @@ -20,15 +20,13 @@ import {ScrobblingService} from "../../_services/scrobbling.service"; import {ScrobbleError} from "../../_models/scrobbling/scrobble-error"; import {SeriesService} from "../../_services/series.service"; -import {EditSeriesModalComponent} from "../../cards/_modals/edit-series-modal/edit-series-modal.component"; -import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; import {FilterPipe} from "../../_pipes/filter.pipe"; import {TranslocoModule} from "@jsverse/transloco"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; import {TranslocoLocaleModule} from "@jsverse/transloco-locale"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; -import {DefaultModalOptions} from "../../_models/default-modal-options"; import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; +import {ActionService} from "../../_services/action.service"; @Component({ selector: 'app-manage-scrobble-errors', @@ -38,14 +36,20 @@ import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; changeDetection: ChangeDetectionStrategy.OnPush }) export class ManageScrobbleErrorsComponent implements OnInit { - @Output() scrobbleCount = new EventEmitter(); - @ViewChildren(SortableHeader) headers!: QueryList>; + protected readonly filter = filter; + protected readonly ColumnMode = ColumnMode; + private readonly scrobbleService = inject(ScrobblingService); private readonly messageHub = inject(MessageHubService); private readonly cdRef = inject(ChangeDetectorRef); private readonly destroyRef = inject(DestroyRef); private readonly seriesService = inject(SeriesService); - private readonly modalService = inject(NgbModal); + private readonly actionService = inject(ActionService); + + + @Output() scrobbleCount = new EventEmitter(); + @ViewChildren(SortableHeader) headers!: QueryList>; + messageHubUpdate$ = this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), filter(m => m.event === EVENTS.ScanSeries), shareReplay()); currentSort = new BehaviorSubject>({column: 'created', direction: 'asc'}); @@ -58,8 +62,6 @@ export class ManageScrobbleErrorsComponent implements OnInit { }); - constructor() {} - ngOnInit() { this.loadData(); @@ -108,13 +110,13 @@ export class ManageScrobbleErrorsComponent implements OnInit { return listItem.comment.toLowerCase().indexOf(query) >= 0 || listItem.details.toLowerCase().indexOf(query) >= 0; } - editSeries(seriesId: number) { + fixMatch(seriesId: number) { this.seriesService.getSeries(seriesId).subscribe(series => { - const modalRef = this.modalService.open(EditSeriesModalComponent, DefaultModalOptions); - modalRef.componentInstance.series = series; + this.actionService.matchSeries(series, (result) => { + if (!result) return; + this.data = [...this.data.filter(s => s.seriesId !== series.id)]; + this.cdRef.markForCheck(); + }); }); } - - protected readonly filter = filter; - protected readonly ColumnMode = ColumnMode; } diff --git a/UI/Web/src/app/announcements/_components/changelog/changelog.component.ts b/UI/Web/src/app/announcements/_components/changelog/changelog.component.ts index 61de45bc9..a464576fb 100644 --- a/UI/Web/src/app/announcements/_components/changelog/changelog.component.ts +++ b/UI/Web/src/app/announcements/_components/changelog/changelog.component.ts @@ -7,7 +7,8 @@ import {AccountService} from "../../../_services/account.service"; import { NgbAccordionBody, - NgbAccordionButton, NgbAccordionCollapse, + NgbAccordionButton, + NgbAccordionCollapse, NgbAccordionDirective, NgbAccordionHeader, NgbAccordionItem @@ -32,7 +33,7 @@ export class ChangelogComponent implements OnInit { isLoading: boolean = true; ngOnInit(): void { - this.serverService.getChangelog(30).subscribe(updates => { + this.serverService.getChangelog(7).subscribe(updates => { this.updates = updates; this.isLoading = false; this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.html b/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.html index c2be04a55..867d7cecd 100644 --- a/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.html +++ b/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.html @@ -5,37 +5,44 @@ {{t('description-part-1')}} {{t('description-part-2')}}

-
- - -
+ @if (relations.length > 0) { +
+ + +
+ }
-
-
- - - {{item.name}} ({{libraryNames[item.libraryId]}}) - - - @if (item.name.toLowerCase().trim().indexOf(value.toLowerCase().trim()) >= 0) { - {{item.name}} - } @else { - {{item.localizedName}} + @for(relation of relations; let idx = $index; track idx) { +
+
+ + + {{item.name}} ({{libraryNames[item.libraryId]}}) + + + @if (item.name.toLowerCase().trim().indexOf(value.toLowerCase().trim()) >= 0) { + {{item.name}} + } @else { + {{item.localizedName}} + } + ({{libraryNames[item.libraryId]}}) + + +
+
+ +
+
-
- -
- -
+ } +
diff --git a/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.ts b/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.ts index 6d3665360..3ac6ca5ed 100644 --- a/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.ts +++ b/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.ts @@ -1,7 +1,8 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, - Component, DestroyRef, + Component, + DestroyRef, EventEmitter, inject, Input, @@ -9,19 +10,18 @@ import { Output } from '@angular/core'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; -import { map, Observable, of, firstValueFrom, ReplaySubject } from 'rxjs'; -import { UtilityService } from 'src/app/shared/_services/utility.service'; -import { TypeaheadSettings } from 'src/app/typeahead/_models/typeahead-settings'; -import { SearchResult } from 'src/app/_models/search/search-result'; -import { Series } from 'src/app/_models/series'; -import { RelationKind, RelationKinds } from 'src/app/_models/series-detail/relation-kind'; -import { ImageService } from 'src/app/_services/image.service'; -import { LibraryService } from 'src/app/_services/library.service'; -import { SearchService } from 'src/app/_services/search.service'; -import { SeriesService } from 'src/app/_services/series.service'; +import {firstValueFrom, map, Observable, of, ReplaySubject} from 'rxjs'; +import {UtilityService} from 'src/app/shared/_services/utility.service'; +import {TypeaheadSettings} from 'src/app/typeahead/_models/typeahead-settings'; +import {SearchResult} from 'src/app/_models/search/search-result'; +import {Series} from 'src/app/_models/series'; +import {RelationKind, RelationKinds} from 'src/app/_models/series-detail/relation-kind'; +import {ImageService} from 'src/app/_services/image.service'; +import {LibraryService} from 'src/app/_services/library.service'; +import {SearchService} from 'src/app/_services/search.service'; +import {SeriesService} from 'src/app/_services/series.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component"; -import {CommonModule} from "@angular/common"; import {TranslocoModule} from "@jsverse/transloco"; import {RelationshipPipe} from "../../_pipes/relationship.pipe"; import {WikiLink} from "../../_models/wiki"; @@ -36,7 +36,6 @@ interface RelationControl { selector: 'app-edit-series-relation', imports: [ TypeaheadComponent, - CommonModule, ReactiveFormsModule, TranslocoModule, RelationshipPipe, @@ -113,7 +112,8 @@ export class EditSeriesRelationComponent implements OnInit { } async addNewRelation() { - this.relations.push({series: undefined, formControl: new FormControl(RelationKind.Adaptation, []), typeaheadSettings: await firstValueFrom(this.createSeriesTypeahead(undefined, RelationKind.Adaptation, this.relations.length))}); + this.relations.push({series: undefined, formControl: new FormControl(RelationKind.Adaptation, []), + typeaheadSettings: await firstValueFrom(this.createSeriesTypeahead(undefined, RelationKind.Adaptation, this.relations.length))}); this.cdRef.markForCheck(); // Focus on the new typeahead diff --git a/UI/Web/src/app/cards/series-card/series-card.component.html b/UI/Web/src/app/cards/series-card/series-card.component.html index d226353cb..4b3a61599 100644 --- a/UI/Web/src/app/cards/series-card/series-card.component.html +++ b/UI/Web/src/app/cards/series-card/series-card.component.html @@ -66,7 +66,7 @@
- + {{series.name}} diff --git a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts index da47fdbe5..35525fa11 100644 --- a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts +++ b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts @@ -1,9 +1,10 @@ -import {DOCUMENT, AsyncPipe, NgStyle} from '@angular/common'; +import {AsyncPipe, DOCUMENT, NgStyle} from '@angular/common'; import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, - Component, DestroyRef, + Component, + DestroyRef, ElementRef, EventEmitter, inject, @@ -14,15 +15,16 @@ import { OnInit, Output, Renderer2, - SimpleChanges, ViewChild + SimpleChanges, + ViewChild } from '@angular/core'; import {BehaviorSubject, fromEvent, map, Observable, of, ReplaySubject} from 'rxjs'; -import { debounceTime } from 'rxjs/operators'; -import { ScrollService } from 'src/app/_services/scroll.service'; -import { ReaderService } from '../../../_services/reader.service'; -import { PAGING_DIRECTION } from '../../_models/reader-enums'; -import { WebtoonImage } from '../../_models/webtoon-image'; -import { MangaReaderService } from '../../_service/manga-reader.service'; +import {debounceTime} from 'rxjs/operators'; +import {ScrollService} from 'src/app/_services/scroll.service'; +import {ReaderService} from '../../../_services/reader.service'; +import {PAGING_DIRECTION} from '../../_models/reader-enums'; +import {WebtoonImage} from '../../_models/webtoon-image'; +import {MangaReaderService} from '../../_service/manga-reader.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {TranslocoDirective} from "@jsverse/transloco"; import {InfiniteScrollModule} from "ngx-infinite-scroll"; @@ -352,17 +354,17 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, this.cdRef.markForCheck(); } - if (!this.isScrolling) { - // Use offset of the image against the scroll container to test if the most of the image is visible on the screen. We can use this - // to mark the current page and separate the prefetching code. - const midlineImages = Array.from(document.querySelectorAll('img[id^="page-"]')) - .filter(entry => this.shouldElementCountAsCurrentPage(entry)); - - if (midlineImages.length > 0) { - this.setPageNum(parseInt(midlineImages[0].getAttribute('page') || this.pageNum + '', 10)); - } - } - + // if (!this.isScrolling) { + // // Use offset of the image against the scroll container to test if the most of the image is visible on the screen. We can use this + // // to mark the current page and separate the prefetching code. + // const midlineImages = Array.from(document.querySelectorAll('img[id^="page-"]')) + // .filter(entry => this.shouldElementCountAsCurrentPage(entry)); + // + // if (midlineImages.length > 0) { + // this.setPageNum(parseInt(midlineImages[0].getAttribute('page') || this.pageNum + '', 10)); + // } + // } + // // Check if we hit the last page this.checkIfShouldTriggerContinuousReader(); } @@ -426,8 +428,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, this.checkIfShouldTriggerContinuousReader() } else if (totalScroll >= totalHeight + SPACER_SCROLL_INTO_PX && this.atBottom) { // This if statement will fire once we scroll into the spacer at all - this.loadNextChapter.emit(); - this.cdRef.markForCheck(); + this.moveToNextChapter(); } } else { // < 5 because debug mode and FF (mobile) can report non 0, despite being at 0 @@ -442,7 +443,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, const reader = this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body; requestAnimationFrame(() => this.scrollService.scrollTo((SPACER_SCROLL_INTO_PX / 2), reader)); } else if (this.getScrollTop() < 5 && this.pageNum === 0 && this.atTop) { - // If already at top, then we moving on + // If already at top, then we are moving on this.loadPrevChapter.emit(); this.cdRef.markForCheck(); } @@ -597,7 +598,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, handleBottomIntersection(entries: IntersectionObserverEntry[]) { if (entries.length > 0 && this.pageNum > this.totalPages - 5 && this.initFinished) { this.debugLog('[Intersection] The whole bottom spacer is visible', entries[0].isIntersecting); - this.loadNextChapter.emit(); + this.moveToNextChapter(); } } @@ -617,6 +618,14 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, }); } + /** + * Move to the next chapter and set the page + */ + moveToNextChapter() { + this.setPageNum(this.totalPages); + this.loadNextChapter.emit(); + } + /** * Set the page number, invoke prefetching and optionally scroll to the new page. * @param pageNum Page number to set to. Will trigger the pageNumberChange event emitter. diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts index 1ad78880b..595ae6079 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts @@ -621,11 +621,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.cdRef.markForCheck(); }); - // fromEvent(this.readingArea.nativeElement, 'click').pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe((event: MouseEvent | any) => { - // if (event.detail > 1) return; - // this.toggleMenu(); - // }); - fromEvent(this.readingArea.nativeElement, 'scroll').pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(() => { this.prevScrollLeft = this.readingArea?.nativeElement?.scrollLeft || 0; this.prevScrollTop = this.readingArea?.nativeElement?.scrollTop || 0; @@ -640,6 +635,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.navService.showSideNav(); this.showBookmarkEffectEvent.complete(); if (this.goToPageEvent !== undefined) this.goToPageEvent.complete(); + this.readerService.disableWakeLock(); } @@ -784,6 +780,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { switchToWebtoonReaderIfPagesLikelyWebtoon() { if (this.readerMode === ReaderMode.Webtoon) return; + if (!this.user.preferences.allowAutomaticWebtoonReaderDetection) return; if (this.mangaReaderService.shouldBeWebtoonMode()) { this.readerMode = ReaderMode.Webtoon; diff --git a/UI/Web/src/app/manga-reader/_service/manga-reader.service.ts b/UI/Web/src/app/manga-reader/_service/manga-reader.service.ts index a2975fd24..084b1c0e7 100644 --- a/UI/Web/src/app/manga-reader/_service/manga-reader.service.ts +++ b/UI/Web/src/app/manga-reader/_service/manga-reader.service.ts @@ -1,11 +1,11 @@ -import { ElementRef, Injectable, Renderer2, RendererFactory2 } from '@angular/core'; -import { PageSplitOption } from 'src/app/_models/preferences/page-split-option'; -import { ScalingOption } from 'src/app/_models/preferences/scaling-option'; -import { ReaderService } from 'src/app/_services/reader.service'; -import { ChapterInfo } from '../_models/chapter-info'; -import { DimensionMap } from '../_models/file-dimension'; -import { FITTING_OPTION } from '../_models/reader-enums'; -import { BookmarkInfo } from 'src/app/_models/manga-reader/bookmark-info'; +import {ElementRef, Injectable, Renderer2, RendererFactory2} from '@angular/core'; +import {PageSplitOption} from 'src/app/_models/preferences/page-split-option'; +import {ScalingOption} from 'src/app/_models/preferences/scaling-option'; +import {ReaderService} from 'src/app/_services/reader.service'; +import {ChapterInfo} from '../_models/chapter-info'; +import {DimensionMap} from '../_models/file-dimension'; +import {FITTING_OPTION} from '../_models/reader-enums'; +import {BookmarkInfo} from 'src/app/_models/manga-reader/bookmark-info'; @Injectable({ providedIn: 'root' @@ -113,10 +113,12 @@ export class MangaReaderService { return !(this.isNoSplit(pageSplitOption) || !needsSplitting) } + /** + * Some pages aren't cover images but might need fit split renderings + * @param pageSplitOption + */ shouldRenderAsFitSplit(pageSplitOption: PageSplitOption) { - // Some pages aren't cover images but might need fit split renderings - if (parseInt(pageSplitOption + '', 10) !== PageSplitOption.FitSplit) return false; - return true; + return parseInt(pageSplitOption + '', 10) === PageSplitOption.FitSplit; } @@ -156,27 +158,97 @@ export class MangaReaderService { shouldBeWebtoonMode() { const pages = Object.values(this.pageDimensions); + // Require a minimum number of pages for reliable detection + if (pages.length < 3) { + return false; + } + + // Get statistical properties across all pages + const aspectRatios = pages.map(info => info.height / info.width); + const avgAspectRatio = aspectRatios.reduce((sum, ratio) => sum + ratio, 0) / pages.length; + const stdDevAspectRatio = Math.sqrt( + aspectRatios.reduce((sum, ratio) => sum + Math.pow(ratio - avgAspectRatio, 2), 0) / pages.length + ); + + // Consider page dimensions consistency + const widths = pages.map(info => info.width); + const heights = pages.map(info => info.height); + const avgWidth = widths.reduce((sum, w) => sum + w, 0) / pages.length; + const avgHeight = heights.reduce((sum, h) => sum + h, 0) / pages.length; + + // Calculate variation coefficients for width and height + const widthVariation = Math.sqrt( + widths.reduce((sum, w) => sum + Math.pow(w - avgWidth, 2), 0) / pages.length + ) / avgWidth; + + // Calculate individual scores for each page let webtoonScore = 0; + let strongIndicatorCount = 0; + pages.forEach(info => { const aspectRatio = info.height / info.width; let score = 0; // Strong webtoon indicator: If aspect ratio is at least 2:1 - if (aspectRatio >= 2) { + if (aspectRatio >= 2.2) { score += 1; + strongIndicatorCount++; + } else if (aspectRatio >= 1.8 && aspectRatio < 2.2) { + // Moderate indicator + score += 0.5; + } else if (aspectRatio >= 1.5 && aspectRatio < 1.8) { + // Weak indicator - many regular manga/comics have ratios in this range + score += 0.2; } - // Boost score if width is small (≤ 800px, common in webtoons) + // Penalize pages that are too square-like (common in traditional comics) + if (aspectRatio < 1.2) { + score -= 0.5; + } + + // Consider width but with less weight than before if (info.width <= 750) { - score += 0.5; // Adjust weight as needed + score += 0.2; + } + + // Consider absolute height (long strips tend to be very tall) + if (info.height > 2000) { + score += 0.5; + } else if (info.height > 1500) { + score += 0.3; + } + + // Consider absolute page area - webtoons tend to have larger total area + const area = info.width * info.height; + if (area > 1500000) { // e.g., 1000×1500 or larger + score += 0.3; } webtoonScore += score; }); + const averageScore = webtoonScore / pages.length; - // If at least 50% of the pages fit the webtoon criteria, switch to Webtoon mode. - return webtoonScore / pages.length >= 0.5; + // Multiple criteria for more robust detection + // Check for typical manga/comic dimensions that should NOT be webtoon mode + const isMangaLikeSize = avgHeight < 1200 && avgAspectRatio < 1.7 && avgWidth < 700; + + // Main detection criteria + return ( + // Primary criterion: average score threshold (increased) + averageScore >= 0.7 && + // Not resembling typical manga/comic dimensions + !isMangaLikeSize && + // Secondary criteria (any one can satisfy) + ( + // Most pages should have high aspect ratio + (strongIndicatorCount / pages.length >= 0.4) || + // Average aspect ratio is high enough (increased threshold) + (avgAspectRatio >= 2.0) || + // Pages have consistent width AND very high aspect ratio + (widthVariation < 0.15 && avgAspectRatio > 1.8) + ) + ); } diff --git a/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.html b/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.html index 9d4a823aa..801df6699 100644 --- a/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.html +++ b/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.html @@ -1,59 +1,67 @@ + @if (items.length > virtualizeAfter) { +
+ +
- -
- -
+
+ + -
- - - - -
-
- -
- - - -
-
-
- - - - -
+
-
-
+
+ +
+ } @else { +
+ @for(item of items; track item; let i = $index) { +
+
+ + + + @if (showRemoveButton) { + + } + +
+
+ } +
+ }
-
- - - - - - - - + @if (accessibilityMode || bulkMode) { +
+ @if (accessibilityMode) { + + + } + + @if (bulkMode) { + + + }
- + } + + @if (!isVirtualized && !(accessibilityMode || bulkMode) && !disabled) { + + }
- diff --git a/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.ts b/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.ts index 489a4fb2d..1f0f0e75b 100644 --- a/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.ts +++ b/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.ts @@ -13,7 +13,7 @@ import { TrackByFunction } from '@angular/core'; import {VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller'; -import {NgClass, NgFor, NgIf, NgTemplateOutlet} from '@angular/common'; +import {NgClass, NgFor, NgTemplateOutlet} from '@angular/common'; import {TranslocoDirective} from "@jsverse/transloco"; import {BulkSelectionService} from "../../../cards/bulk-selection.service"; import {FormsModule} from "@angular/forms"; @@ -36,11 +36,15 @@ export interface ItemRemoveEvent { templateUrl: './draggable-ordered-list.component.html', styleUrls: ['./draggable-ordered-list.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [NgIf, VirtualScrollerModule, NgFor, NgTemplateOutlet, CdkDropList, CdkDrag, + imports: [VirtualScrollerModule, NgFor, NgTemplateOutlet, CdkDropList, CdkDrag, CdkDragHandle, TranslocoDirective, NgClass, FormsModule] }) export class DraggableOrderedListComponent { + protected readonly bulkSelectionService = inject(BulkSelectionService); + private readonly destroyRef = inject(DestroyRef); + + /** * After this many elements, drag and drop is disabled and we use a virtualized list instead */ @@ -59,6 +63,10 @@ export class DraggableOrderedListComponent { * Disables drag and drop functionality. Useful if a filter is present which will skew actual index. */ @Input() disabled: boolean = false; + /** + * Disables remove button + */ + @Input() disableRemove: boolean = false; /** * When enabled, draggability is disabled and a checkbox renders instead of order box or drag handle */ @@ -71,8 +79,6 @@ export class DraggableOrderedListComponent { @Output() itemRemove: EventEmitter = new EventEmitter(); @ContentChild('draggableItem') itemTemplate!: TemplateRef; - public readonly bulkSelectionService = inject(BulkSelectionService); - public readonly destroyRef = inject(DestroyRef); get BufferAmount() { return Math.min(this.items.length / 20, 20); diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html index 55ffee870..3fb75269c 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html @@ -1,168 +1,266 @@
- -

- {{readingList?.title}} - @if (readingList?.promoted) { - () - } - @if (actions.length > 0) { - - } -

-
{{t('item-count', {num: items.length | number})}}
- - - @if (readingList) { -
-
-

{{t('page-settings-title')}}

- -
-
-
-
- - - @if (!(readingList.promoted && !this.isAdmin)) { -
-
- - -
-
- } -
-
-
+
+ @if (readingList) { +
+
+
+
- } - - - @if (readingList) { -
+
+

+ {{readingList.title}} + @if (readingList.promoted) { + () + } -
-
- -
-
-
-
- -
- -
- - -
-
-
-
- - @if (readingList.startingYear !== 0) { -
-

- @if (readingList.startingMonth > 0) { - {{(readingList.startingMonth +'/01/2020')| date:'MMM'}} - } - @if (readingList.startingMonth > 0 && readingList.startingYear > 0) { - , - } - @if (readingList.startingYear > 0) { - {{readingList.startingYear}} - } - — - @if (readingList.endingYear > 0) { - @if (readingList.endingMonth > 0) { - {{(readingList.endingMonth +'/01/2020')| date:'MMM'}} - } - @if (readingList.endingMonth > 0 && readingList.endingYear > 0) { - , - } - @if (readingList.endingYear > 0) { - {{readingList.endingYear}} - } - } -

-
- } - - - -
- -
- - @if (characters$ | async; as characters) { - @if (characters && characters.length > 0) { -
-
-
{{t('characters-title')}}
- - - {{item.name}} - - -
+ @if( isLoading) { +
+ loading...
} - } + + +

+ + + + + + + + + + + +
+
+
+ +
+ +
+ + +
+
+
+ + + @if (isOwnedReadingList) { +
+ +
+ } + + +
+
+ +
+
+ +
+ +
+
+
+ +
+ +
+ +
+
+
+ {{t('date-range-title')}} +
+ @if (readingList.startingYear !== 0) { + @if (readingList.startingMonth > 0) { + {{(readingList.startingMonth +'/01/2020')| date:'MMM'}} + } + @if (readingList.startingMonth > 0 && readingList.startingYear > 0) { + , + } + @if (readingList.startingYear > 0) { + {{readingList.startingYear}} + } + — + @if (readingList.endingYear > 0) { + @if (readingList.endingMonth > 0) { + {{(readingList.endingMonth +'/01/2020')| date:'MMM'}} + } + @if (readingList.endingMonth > 0 && readingList.endingYear > 0) { + , + } + @if (readingList.endingYear > 0) { + {{readingList.endingYear}} + } + } + } @else { + {{null | defaultValue}} + } +
+
+
+ {{t('items-title')}} +
+ {{t('item-count', {num: items.length | number})}} +
+
+
+
+ +
+
+
+ {{t('writers-title')}} +
+
+ + + {{item.name}} + + +
+
+
+ +
+ {{t('cover-artists-title')}} +
+
+ + + {{item.name}} + + +
+
+
+
+
+ +
+ +
+ + @if (formGroup.get('edit')?.value) { + @if (!readingList.promoted && this.isOwnedReadingList) { +
+ +
+ } + +
+
+ + +
+
+ } +
+
-
- @if (items.length === 0 && !isLoading) { -
- {{t('no-data')}} -
- } @else if(isLoading) { - - } + +
} +
diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.scss b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.scss index 281507fff..8d364c347 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.scss +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.scss @@ -1,3 +1,6 @@ +@use '../../../../series-detail-common'; + + .main-container { margin-top: 10px; padding: 0 0 0 10px; diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts index 06e9e977e..64a933552 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts @@ -1,12 +1,23 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; -import {ActivatedRoute, Router} from '@angular/router'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + ElementRef, + Inject, + inject, + OnInit, + ViewChild +} from '@angular/core'; +import {ActivatedRoute, Router, RouterLink} from '@angular/router'; +import {AsyncPipe, DatePipe, DecimalPipe, DOCUMENT, Location, NgClass, NgStyle} from '@angular/common'; import {ToastrService} from 'ngx-toastr'; import {take} from 'rxjs/operators'; import {ConfirmService} from 'src/app/shared/confirm.service'; import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service'; import {LibraryType} from 'src/app/_models/library/library'; import {MangaFormat} from 'src/app/_models/manga-format'; -import {ReadingList, ReadingListItem} from 'src/app/_models/reading-list'; +import {ReadingList, ReadingListInfo, ReadingListItem} from 'src/app/_models/reading-list'; import {AccountService} from 'src/app/_services/account.service'; import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service'; import {ActionService} from 'src/app/_services/action.service'; @@ -16,57 +27,84 @@ import { DraggableOrderedListComponent, IndexUpdateEvent } from '../draggable-ordered-list/draggable-ordered-list.component'; -import {forkJoin, Observable} from 'rxjs'; +import {forkJoin, startWith, tap} from 'rxjs'; import {ReaderService} from 'src/app/_services/reader.service'; import {LibraryService} from 'src/app/_services/library.service'; -import {Person} from 'src/app/_models/metadata/person'; import {ReadingListItemComponent} from '../reading-list-item/reading-list-item.component'; import {LoadingComponent} from '../../../shared/loading/loading.component'; import {BadgeExpanderComponent} from '../../../shared/badge-expander/badge-expander.component'; import {ReadMoreComponent} from '../../../shared/read-more/read-more.component'; -import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '@ng-bootstrap/ng-bootstrap'; -import {ImageComponent} from '../../../shared/image/image.component'; -import {AsyncPipe, DatePipe, DecimalPipe, NgClass} from '@angular/common'; import { - SideNavCompanionBarComponent -} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; + NgbDropdown, + NgbDropdownItem, + NgbDropdownMenu, + NgbDropdownToggle, + NgbNav, + NgbNavChangeEvent, + NgbNavContent, + NgbNavItem, + NgbNavLink, + NgbNavOutlet, + NgbTooltip +} from '@ng-bootstrap/ng-bootstrap'; +import {ImageComponent} from '../../../shared/image/image.component'; import {translate, TranslocoDirective} from "@jsverse/transloco"; import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component"; -import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service"; -import {FilterField} from "../../../_models/metadata/v2/filter-field"; -import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison"; import {Title} from "@angular/platform-browser"; +import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; +import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller"; +import {PromotedIconComponent} from "../../../shared/_components/promoted-icon/promoted-icon.component"; +import {DefaultValuePipe} from "../../../_pipes/default-value.pipe"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {DetailsTabComponent} from "../../../_single-module/details-tab/details-tab.component"; +import {IHasCast} from "../../../_models/common/i-has-cast"; + +enum TabID { + Storyline = 'storyline-tab', + Volumes = 'volume-tab', + Details = 'details-tab', +} @Component({ selector: 'app-reading-list-detail', templateUrl: './reading-list-detail.component.html', styleUrls: ['./reading-list-detail.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [SideNavCompanionBarComponent, CardActionablesComponent, ImageComponent, NgbDropdown, + imports: [CardActionablesComponent, ImageComponent, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, ReadMoreComponent, BadgeExpanderComponent, LoadingComponent, DraggableOrderedListComponent, - ReadingListItemComponent, NgClass, AsyncPipe, DecimalPipe, DatePipe, TranslocoDirective] + ReadingListItemComponent, NgClass, AsyncPipe, DecimalPipe, DatePipe, TranslocoDirective, ReactiveFormsModule, + NgbNav, NgbNavContent, NgbNavLink, NgbTooltip, + RouterLink, VirtualScrollerModule, NgStyle, NgbNavOutlet, NgbNavItem, PromotedIconComponent, DefaultValuePipe, DetailsTabComponent] }) export class ReadingListDetailComponent implements OnInit { + protected readonly MangaFormat = MangaFormat; + protected readonly Breakpoint = Breakpoint; + protected readonly TabID = TabID; + protected readonly encodeURIComponent = encodeURIComponent; + private route = inject(ActivatedRoute); private router = inject(Router); private readingListService = inject(ReadingListService); private actionService = inject(ActionService); private actionFactoryService = inject(ActionFactoryService); - public utilityService = inject(UtilityService); - public imageService = inject(ImageService); + protected utilityService = inject(UtilityService); + protected imageService = inject(ImageService); private accountService = inject(AccountService); private toastr = inject(ToastrService); private confirmService = inject(ConfirmService); private libraryService = inject(LibraryService); private readerService = inject(ReaderService); private cdRef = inject(ChangeDetectorRef); - private filterUtilityService = inject(FilterUtilitiesService); private titleService = inject(Title); + private location = inject(Location); + private destroyRef = inject(DestroyRef); - protected readonly MangaFormat = MangaFormat; - protected readonly Breakpoint = Breakpoint; + + + @ViewChild('scrollingBlock') scrollingBlock: ElementRef | undefined; + @ViewChild('companionBar') companionBar: ElementRef | undefined; items: Array = []; listId!: number; @@ -75,12 +113,62 @@ export class ReadingListDetailComponent implements OnInit { isAdmin: boolean = false; isLoading: boolean = false; accessibilityMode: boolean = false; + editMode: boolean = false; readingListSummary: string = ''; libraryTypes: {[key: number]: LibraryType} = {}; - characters$!: Observable; + activeTabId = TabID.Storyline; + showStorylineTab = true; + isOwnedReadingList: boolean = false; + rlInfo: ReadingListInfo | null = null; + castInfo: IHasCast = { + characterLocked: false, + characters: [], + coloristLocked: false, + colorists: [], + coverArtistLocked: false, + coverArtists: [], + editorLocked: false, + editors: [], + imprintLocked: false, + imprints: [], + inkerLocked: false, + inkers: [], + languageLocked: false, + lettererLocked: false, + letterers: [], + locationLocked: false, + locations: [], + pencillerLocked: false, + pencillers: [], + publisherLocked: false, + publishers: [], + teamLocked: false, + teams: [], + translatorLocked: false, + translators: [], + writerLocked: false, + writers: [] + }; + + formGroup = new FormGroup({ + 'edit': new FormControl(false, []), + 'accessibilityMode': new FormControl(false, []), + }); + get ScrollingBlockHeight() { + if (this.scrollingBlock === undefined) return 'calc(var(--vh)*100)'; + const navbar = this.document.querySelector('.navbar') as HTMLElement; + if (navbar === null) return 'calc(var(--vh)*100)'; + + const companionHeight = this.companionBar?.nativeElement.offsetHeight || 0; + const navbarHeight = navbar.offsetHeight; + const totalHeight = companionHeight + navbarHeight + 21; //21px to account for padding + return 'calc(var(--vh)*100 - ' + totalHeight + 'px)'; + } + + constructor(@Inject(DOCUMENT) private document: Document) {} ngOnInit(): void { @@ -92,9 +180,45 @@ export class ReadingListDetailComponent implements OnInit { } this.listId = parseInt(listId, 10); - this.characters$ = this.readingListService.getCharacters(this.listId); + + + this.readingListService.getAllPeople(this.listId).subscribe(allPeople => { + this.castInfo = allPeople; + this.cdRef.markForCheck(); + }); + + + + + this.readingListService.getReadingListInfo(this.listId).subscribe(info => { + this.rlInfo = info; + this.cdRef.markForCheck(); + }); + + this.formGroup.get('edit')!.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef), + startWith(false), + tap(mode => { + this.editMode = (mode || false); + this.cdRef.markForCheck(); + }) + ).subscribe(); + + this.formGroup.get('accessibilityMode')!.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef), + startWith(this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet), + tap(mode => { + this.accessibilityMode = (mode || this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet); + this.cdRef.markForCheck(); + }) + ).subscribe(); + + if (this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet) { + this.formGroup.get('accessibilityMode')?.disable(); + } this.accessibilityMode = this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet; + this.editMode = false; this.cdRef.markForCheck(); forkJoin([ @@ -104,7 +228,7 @@ export class ReadingListDetailComponent implements OnInit { const libraries = results[0]; const readingList = results[1]; - this.titleService.setTitle('Kavita - ' + readingList.title); + libraries.forEach(lib => { this.libraryTypes[lib.id] = lib.type; @@ -116,8 +240,10 @@ export class ReadingListDetailComponent implements OnInit { this.router.navigateByUrl('library'); return; } + this.readingList = readingList; this.readingListSummary = (this.readingList.summary === null ? '' : this.readingList.summary).replace(/\n/g, '
'); + this.titleService.setTitle('Kavita - ' + readingList.title); this.cdRef.markForCheck(); @@ -127,10 +253,12 @@ export class ReadingListDetailComponent implements OnInit { this.actions = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this)) .filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin)); + this.isOwnedReadingList = this.actions.filter(a => a.action === Action.Edit).length > 0; this.cdRef.markForCheck(); } }); }); + this.getListItems(); } @@ -163,14 +291,7 @@ export class ReadingListDetailComponent implements OnInit { await this.deleteList(readingList); break; case Action.Edit: - this.actionService.editReadingList(readingList, (readingList: ReadingList) => { - // Reload information around list - this.readingListService.getReadingList(this.listId).subscribe(rl => { - this.readingList = rl; - this.readingListSummary = (this.readingList.summary === null ? '' : this.readingList.summary).replace(/\n/g, '
'); - this.cdRef.markForCheck(); - }); - }); + this.editReadingList(readingList); break; case Action.Promote: this.actionService.promoteMultipleReadingLists([this.readingList!], true, () => { @@ -191,6 +312,17 @@ export class ReadingListDetailComponent implements OnInit { } } + editReadingList(readingList: ReadingList) { + this.actionService.editReadingList(readingList, (readingList: ReadingList) => { + // Reload information around list + this.readingListService.getReadingList(this.listId).subscribe(rl => { + this.readingList = rl!; + this.readingListSummary = (this.readingList.summary === null ? '' : this.readingList.summary).replace(/\n/g, '
'); + this.cdRef.markForCheck(); + }); + }); + } + async deleteList(readingList: ReadingList) { if (!await this.confirmService.confirm(translate('toasts.confirm-delete-reading-list'))) return; @@ -260,7 +392,32 @@ export class ReadingListDetailComponent implements OnInit { this.cdRef.markForCheck(); } - goToCharacter(character: Person) { - this.filterUtilityService.applyFilter(['all-series'], FilterField.Characters, FilterComparison.Contains, character.id + '').subscribe(); + + toggleReorder() { + this.formGroup.get('edit')?.setValue(!this.formGroup.get('edit')!.value); + this.cdRef.markForCheck(); + } + + + onNavChange(event: NgbNavChangeEvent) { + this.updateUrl(event.nextId); + this.cdRef.markForCheck(); + } + + private updateUrl(activeTab: TabID) { + const tokens = this.location.path().split('#'); + const newUrl = `${tokens[0]}#${activeTab}`; + this.location.replaceState(newUrl) + } + + switchTabsToDetail() { + this.activeTabId = TabID.Details; + this.cdRef.markForCheck(); + setTimeout(() => { + const tabElem = this.document.querySelector('#details-tab'); + if (tabElem) { + (tabElem as HTMLLIElement).scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); + } + }, 10); } } diff --git a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html index 1cb11b1a8..9104285cf 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html @@ -17,18 +17,23 @@
{{item.title}}
- - + @if (showRemove) { + + } + + @if (showRead) { + + }
diff --git a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.ts b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.ts index cd450e242..acde50022 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.ts +++ b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.ts @@ -24,6 +24,8 @@ export class ReadingListItemComponent { @Input({required: true}) item!: ReadingListItem; @Input() position: number = 0; + @Input() showRemove: boolean = false; + @Input() showRead: boolean = true; @Input() libraryTypes: {[key: number]: LibraryType} = {}; /** * If the Reading List is promoted or not diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html index 34d8ae9b6..167cdf704 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html @@ -89,7 +89,7 @@ @if ((licenseService.hasValidLicense$ | async) && libraryAllowsScrobbling) {
-
diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index 802aa7fb5..6a781b886 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -150,6 +150,16 @@ interface StoryLineItem { }) export class SeriesDetailComponent implements OnInit, AfterContentChecked { + protected readonly LibraryType = LibraryType; + protected readonly TabID = TabID; + protected readonly LooseLeafOrSpecialNumber = LooseLeafOrDefaultNumber; + protected readonly SpecialVolumeNumber = SpecialVolumeNumber; + protected readonly SettingsTabId = SettingsTabId; + protected readonly FilterField = FilterField; + protected readonly AgeRating = AgeRating; + protected readonly Breakpoint = Breakpoint; + protected readonly encodeURIComponent = encodeURIComponent; + private readonly destroyRef = inject(DestroyRef); private readonly route = inject(ActivatedRoute); private readonly seriesService = inject(SeriesService); @@ -180,14 +190,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { private readonly scrobbleService = inject(ScrobblingService); private readonly location = inject(Location); - protected readonly LibraryType = LibraryType; - protected readonly TabID = TabID; - protected readonly LooseLeafOrSpecialNumber = LooseLeafOrDefaultNumber; - protected readonly SpecialVolumeNumber = SpecialVolumeNumber; - protected readonly SettingsTabId = SettingsTabId; - protected readonly FilterField = FilterField; - protected readonly AgeRating = AgeRating; - protected readonly Breakpoint = Breakpoint; @ViewChild('scrollingBlock') scrollingBlock: ElementRef | undefined; @ViewChild('companionBar') companionBar: ElementRef | undefined; @@ -1212,6 +1214,4 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { } }, 10); } - - protected readonly encodeURIComponent = encodeURIComponent; } diff --git a/UI/Web/src/app/settings/_components/setting-item/setting-item.component.html b/UI/Web/src/app/settings/_components/setting-item/setting-item.component.html index cf3639495..41b0345fa 100644 --- a/UI/Web/src/app/settings/_components/setting-item/setting-item.component.html +++ b/UI/Web/src/app/settings/_components/setting-item/setting-item.component.html @@ -15,7 +15,7 @@
@if (showEdit) { - } diff --git a/UI/Web/src/app/settings/_components/setting-title/setting-title.component.html b/UI/Web/src/app/settings/_components/setting-title/setting-title.component.html index 2dd12c0d0..328fc1907 100644 --- a/UI/Web/src/app/settings/_components/setting-title/setting-title.component.html +++ b/UI/Web/src/app/settings/_components/setting-title/setting-title.component.html @@ -15,7 +15,7 @@
- +
diff --git a/UI/Web/src/app/shared/edit-list/edit-list.component.html b/UI/Web/src/app/shared/edit-list/edit-list.component.html index 461fef21e..0231252d6 100644 --- a/UI/Web/src/app/shared/edit-list/edit-list.component.html +++ b/UI/Web/src/app/shared/edit-list/edit-list.component.html @@ -1,24 +1,33 @@
- - @for(item of Items; let i = $index; track item; let isFirst = $first) { -
-
-
- - +
+ @for(item of ItemsArray.controls; let i = $index; track i) { +
+
+
+ + +
+
+
+ +
-
- - -
-
- } - + } +
diff --git a/UI/Web/src/app/shared/edit-list/edit-list.component.ts b/UI/Web/src/app/shared/edit-list/edit-list.component.ts index bab4b16a8..6d21549e8 100644 --- a/UI/Web/src/app/shared/edit-list/edit-list.component.ts +++ b/UI/Web/src/app/shared/edit-list/edit-list.component.ts @@ -9,15 +9,14 @@ import { OnInit, Output } from '@angular/core'; -import {CommonModule} from '@angular/common'; -import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; +import {FormArray, FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; import {TranslocoDirective} from "@jsverse/transloco"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators"; @Component({ selector: 'app-edit-list', - imports: [CommonModule, ReactiveFormsModule, TranslocoDirective], + imports: [ReactiveFormsModule, TranslocoDirective], templateUrl: './edit-list.component.html', styleUrl: './edit-list.component.scss', changeDetection: ChangeDetectionStrategy.OnPush @@ -31,20 +30,16 @@ export class EditListComponent implements OnInit { @Input({required: true}) label = ''; @Output() updateItems = new EventEmitter>(); - form: FormGroup = new FormGroup({}); - private combinedItems: string = ''; + form: FormGroup = new FormGroup({items: new FormArray([])}); - get Items() { - return this.combinedItems.split(',') || ['']; + get ItemsArray(): FormArray { + return this.form.get('items') as FormArray; } ngOnInit() { - this.items.forEach((link, index) => { - this.form.addControl('link' + index, new FormControl(link, [])); - }); + this.items.forEach(item => this.addItem(item)); - this.combinedItems = this.items.join(','); this.form.valueChanges.pipe( debounceTime(100), @@ -55,47 +50,39 @@ export class EditListComponent implements OnInit { this.cdRef.markForCheck(); } + createItemControl(value: string = ''): FormControl { + return new FormControl(value, []); + } + add() { - this.combinedItems += ','; - this.form.addControl('link' + (this.Items.length - 1), new FormControl('', [])); + this.ItemsArray.push(this.createItemControl()); this.emit(); this.cdRef.markForCheck(); } + addItem(value: string) { + this.ItemsArray.push(this.createItemControl(value)); + } + remove(index: number) { - - const initialControls = Object.keys(this.form.controls) - .filter(key => key.startsWith('link')); - - if (index == 0 && initialControls.length === 1) { - this.form.get(initialControls[0])?.setValue('', {emitEvent: true}); + // If it's the last item, just clear its value + if (this.ItemsArray.length === 1) { + this.ItemsArray.at(0).setValue(''); this.emit(); - this.cdRef.markForCheck(); return; } - // Remove the form control explicitly then rebuild the combinedItems - this.form.removeControl('link' + index, {emitEvent: true}); - - this.combinedItems = Object.keys(this.form.controls) - .filter(key => key.startsWith('link')) - .map(key => this.form.get(key)?.value) - .join(','); - - // Recreate form to ensure index's match - this.form = new FormGroup({}); - this.Items.forEach((item, index) => { - this.form.addControl('link' + index, new FormControl(item, [])); - }) - + this.ItemsArray.removeAt(index); this.emit(); this.cdRef.markForCheck(); } + // Emit non-empty item values emit() { - this.updateItems.emit(Object.keys(this.form.controls) - .filter(key => key.startsWith('link')) - .map(key => this.form.get(key)?.value) - .filter(v => v !== null && v !== '')); + const nonEmptyItems = this.ItemsArray.controls + .map(control => control.value) + .filter(value => value !== null && value.trim() !== ''); + + this.updateItems.emit(nonEmptyItems); } } diff --git a/UI/Web/src/app/shared/read-more/read-more.component.ts b/UI/Web/src/app/shared/read-more/read-more.component.ts index 6fb46c1c5..738cb8ee3 100644 --- a/UI/Web/src/app/shared/read-more/read-more.component.ts +++ b/UI/Web/src/app/shared/read-more/read-more.component.ts @@ -54,7 +54,17 @@ export class ReadMoreComponent implements OnChanges { this.hideToggle = false; if (this.isCollapsed) { this.currentText = text.substring(0, this.maxLength); - this.currentText = this.currentText.substring(0, Math.min(this.currentText.length, this.currentText.lastIndexOf(' '))); + + // Find last natural breaking point: space for English, or a CJK character boundary + const match = this.currentText.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]+$/u); + const lastSpace = this.currentText.lastIndexOf(' '); + + if (lastSpace > 0) { + this.currentText = this.currentText.substring(0, lastSpace); // Prefer space for English + } else if (match) { + this.currentText = this.currentText.substring(0, this.currentText.length - match[0].length); // Trim CJK + } + this.currentText = this.currentText + '…'; } else if (!this.isCollapsed) { this.currentText = text; @@ -62,6 +72,7 @@ export class ReadMoreComponent implements OnChanges { this.cdRef.markForCheck(); } + ngOnChanges() { this.determineView(); } diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts index 2d71c9c5b..5c83db623 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts @@ -1,12 +1,4 @@ -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - DestroyRef, - inject, - Input, - OnInit -} from '@angular/core'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; import { NgbActiveModal, @@ -244,12 +236,16 @@ export class LibrarySettingsModalComponent implements OnInit { this.madeChanges = false; + // TODO: Refactor into FormArray for(let fileTypeGroup of allFileTypeGroup) { this.libraryForm.addControl(fileTypeGroup + '', new FormControl(this.library.libraryFileTypes.includes(fileTypeGroup), [])); } + + // TODO: Refactor into FormArray for(let glob of this.library.excludePatterns) { this.libraryForm.addControl('excludeGlob-' , new FormControl(glob, [])); } + this.excludePatterns = this.library.excludePatterns; } else { for(let fileTypeGroup of allFileTypeGroup) { diff --git a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html index 8e380b8f8..3f6e92442 100644 --- a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html +++ b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html @@ -264,6 +264,18 @@
+ +
+ + +
+ +
+
+
+
diff --git a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts index 6090bd36b..a9313a0f2 100644 --- a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts @@ -110,7 +110,7 @@ export class ManageUserPreferencesComponent implements OnInit { get Locale() { if (!this.settingsForm.get('locale')) return 'English'; - return this.locales.filter(l => l.fileName === this.settingsForm.get('locale')!.value)[0].renderName; + return (this.locales || []).filter(l => l.fileName === this.settingsForm.get('locale')!.value)[0].renderName; } @@ -154,6 +154,7 @@ export class ManageUserPreferencesComponent implements OnInit { this.settingsForm.addControl('emulateBook', new FormControl(this.user.preferences.emulateBook, [])); this.settingsForm.addControl('swipeToPaginate', new FormControl(this.user.preferences.swipeToPaginate, [])); this.settingsForm.addControl('backgroundColor', new FormControl(this.user.preferences.backgroundColor, [])); + this.settingsForm.addControl('allowAutomaticWebtoonReaderDetection', new FormControl(this.user.preferences.allowAutomaticWebtoonReaderDetection, [])); this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.user.preferences.bookReaderFontFamily, [])); this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.user.preferences.bookReaderFontSize, [])); @@ -226,6 +227,7 @@ export class ManageUserPreferencesComponent implements OnInit { this.settingsForm.get('emulateBook')?.setValue(this.user.preferences.emulateBook, {onlySelf: true, emitEvent: false}); this.settingsForm.get('swipeToPaginate')?.setValue(this.user.preferences.swipeToPaginate, {onlySelf: true, emitEvent: false}); this.settingsForm.get('backgroundColor')?.setValue(this.user.preferences.backgroundColor, {onlySelf: true, emitEvent: false}); + this.settingsForm.get('allowAutomaticWebtoonReaderDetection')?.setValue(this.user.preferences.allowAutomaticWebtoonReaderDetection, {onlySelf: true, emitEvent: false}); this.settingsForm.get('bookReaderFontFamily')?.setValue(this.user.preferences.bookReaderFontFamily, {onlySelf: true, emitEvent: false}); this.settingsForm.get('bookReaderFontSize')?.setValue(this.user.preferences.bookReaderFontSize, {onlySelf: true, emitEvent: false}); @@ -265,6 +267,7 @@ export class ManageUserPreferencesComponent implements OnInit { readerMode: parseInt(modelSettings.readerMode, 10), layoutMode: parseInt(modelSettings.layoutMode, 10), showScreenHints: modelSettings.showScreenHints, + allowAutomaticWebtoonReaderDetection: modelSettings.allowAutomaticWebtoonReaderDetection, backgroundColor: modelSettings.backgroundColor || '#000', bookReaderFontFamily: modelSettings.bookReaderFontFamily, bookReaderLineSpacing: modelSettings.bookReaderLineSpacing, diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 85741790d..dd7511398 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -156,6 +156,8 @@ "emulate-comic-book-tooltip": "Applies a shadow effect to emulate reading from a book", "swipe-to-paginate-label": "Swipe to Paginate", "swipe-to-paginate-tooltip": "Should swiping on the screen cause the next or previous page to be triggered", + "allow-auto-webtoon-reader-label": "Automatic Webtoon Reader Mode", + "allow-auto-webtoon-reader-tooltip": "Switch into Webtoon Reader mode if pages look like a webtoon. Some false positives may occur.", "book-reader-settings-title": "Book Reader", "tap-to-paginate-label": "Tap to Paginate", @@ -973,9 +975,11 @@ "more-alt": "More", "time-left-alt": "Time Left", "time-to-read-alt": "{{sort-field-pipe.time-to-read}}", - "scrobbling-tooltip": "{{settings.scrobbling}}", + "scrobbling-tooltip": "{{settings.scrobbling}}: {{value}}", "publication-status-title": "Publication", - "publication-status-tooltip": "Publication Status" + "publication-status-tooltip": "Publication Status", + "on": "{{reader-settings.on}}", + "off": "{{reader-settings.off}}" }, "match-series-modal": { @@ -1241,7 +1245,8 @@ "language-title": "{{edit-chapter-modal.language-label}}", "release-title": "{{sort-field-pipe.release-year}}", "format-title": "{{metadata-filter.format-label}}", - "length-title": "{{edit-chapter-modal.words-label}}" + "length-title": "{{edit-chapter-modal.words-label}}", + "age-rating-title": "{{metadata-fields.age-rating-title}}" }, "related-tab": { @@ -1340,7 +1345,7 @@ "reset": "{{common.reset}}", "test": "Test", "host-name-label": "Host Name", - "host-name-tooltip": "Domain Name (of Reverse Proxy). Required for email functionality. If no reverse proxy, use any url.", + "host-name-tooltip": "The domain name of your reverse proxy, required for email functionality. If you’re not using a reverse proxy, you can use any URL, including http://externalip:port/", "host-name-validation": "Host name must start with http(s) and not end in /", "sender-address-label": "Sender Address", @@ -1751,7 +1756,19 @@ "read-options-alt": "Read options", "incognito-alt": "(Incognito)", "no-data": "Nothing added", - "characters-title": "{{metadata-fields.characters-title}}" + "characters-title": "{{metadata-fields.characters-title}}", + "writers-title": "{{metadata-fields.writers-title}}", + "cover-artists-title": "{{metadata-fields.cover-artists-title}}", + "publishers-title": "{{metadata-fields.publishers-title}}", + "items-title": "Items", + "storyline-tab": "{{series-detail.storyline-tab}}", + "details-tab": "{{series-detail.details-tab}}", + "edit-alt": "{{common.edit}}", + "edit-label": "Edit Mode", + "date-range-title": "Date Range", + "more-alt": "{{series-detail.more-alt}}", + "reorder-alt": "Reorder Items", + "dnd-warning": "Drag and drop is unavailable on mobile devices or when the reading list has more than 100 items." }, "events-widget": { @@ -2617,7 +2634,8 @@ "person-image-downloaded": "Person cover was downloaded and applied.", "bulk-delete-libraries": "Are you sure you want to delete {{count}} libraries?", "match-success": "Series matched correctly", - "webtoon-override": "Switching to Webtoon mode due to images representing a webtoon." + "webtoon-override": "Switching to Webtoon mode due to images representing a webtoon.", + "scrobble-gen-init": "Enqueued a job to generate scrobble events from past reading history and ratings, syncing them with connected services." }, "read-time-pipe": { diff --git a/UI/Web/src/theme/components/_buttons.scss b/UI/Web/src/theme/components/_buttons.scss index 1e2aa211c..09ce4157b 100644 --- a/UI/Web/src/theme/components/_buttons.scss +++ b/UI/Web/src/theme/components/_buttons.scss @@ -38,15 +38,55 @@ } .btn-outline-secondary { + /** + --btn-secondary-text-color: white; + --btn-secondary-bg-color: #6c757d; + --btn-secondary-border-color: #6c757d; + --btn-secondary-hover-bg-color: var(--bs-btn-hover-bg); + --btn-secondary-hover-border-color: var(--bs-btn-hover-border-color); + --btn-secondary-hover-border-color: var(--bs-btn-hover-border-color); + --btn-secondary-font-weight: bold; + --btn-secondary-outline-text-color: white; + --btn-secondary-outline-bg-color: transparent; + --btn-secondary-outline-border-color: #6c757d; + --btn-secondary-outline-hover-bg-color: transparent; + --btn-secondary-outline-hover-border-color: transparent; + --btn-secondary-outline-font-weight: bold; + + vs bootstrap + --bs-btn-color: var(--btn-secondary-bg-color); + --bs-btn-border-color: var(--btn-secondary-border-color); + --bs-btn-hover-color: var(--btn-secondary-hover-text-color); + --bs-btn-hover-bg: var(--btn-secondary-outline-hover-bg-color); + --bs-btn-hover-border-color: var(--btn-secondary-outline-hover-border-color); + --bs-btn-focus-shadow-rgb: 108, 117, 125; + --bs-btn-active-color: #fff; + --bs-btn-active-bg: #6c757d; + --bs-btn-active-border-color: #6c757d; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #6c757d; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #6c757d; + --bs-gradient: none; + */ + + // Override bootstrap variables + --bs-btn-color: var(--btn-secondary-bg-color); + --bs-btn-border-color: var(--btn-secondary-border-color); + --bs-btn-hover-color: var(--btn-secondary-hover-text-color); + --bs-btn-hover-bg: var(--btn-secondary-outline-hover-bg-color); + --bs-btn-hover-border-color: var(--btn-secondary-outline-hover-border-color); + + color: var(--btn-secondary-outline-text-color); background-color: var(--btn-secondary-outline-bg-color); border-color: var(--btn-secondary-outline-border-color); - border-radius: 0; &:hover { --bs-btn-color: var(--btn-secondary-outline-hover-text-color); --bs-btn-hover-bg: var(-btn-secondary-outline-hover-bg-color); --bs-btn-hover-border-color: var(--btn-secondary-outline-hover-border-color); + --btn-secondary-outline-text-color: var(--btn-secondary-outline-hover-bg-color); color: var(--btn-secondary-outline-hover-text-color); background-color: var(--btn-secondary-outline-hover-bg-color); @@ -109,17 +149,6 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di } } -.btn-text { - color: var(--primary-color); - > i { - color: var(--primary-color) !important; - } - - &:hover, &:focus { - color: var(--primary-color); - } -} - .btn:focus, .btn:active, .btn:active:focus { box-shadow: 0 0 0 0 var(---btn-focus-boxshadow-color) !important; @@ -131,6 +160,10 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di color: var(--body-text-color); border: none; + &:disabled { + --bs-btn-disabled-bg: transparent; + } + &:hover, &:focus { color: var(--body-text-color); border: none; diff --git a/UI/Web/src/theme/themes/dark.scss b/UI/Web/src/theme/themes/dark.scss index edc1a120f..9db36ca84 100644 --- a/UI/Web/src/theme/themes/dark.scss +++ b/UI/Web/src/theme/themes/dark.scss @@ -146,9 +146,10 @@ --btn-secondary-font-weight: bold; --btn-secondary-outline-text-color: white; --btn-secondary-outline-bg-color: transparent; - --btn-secondary-outline-border-color: transparent; - --btn-secondary-outline-hover-bg-color: transparent; - --btn-secondary-outline-hover-border-color: transparent; + --btn-secondary-outline-border-color: #6c757d; + --btn-secondary-outline-hover-text-color: #fff; + --btn-secondary-outline-hover-bg-color: var(--btn-secondary-bg-color); + --btn-secondary-outline-hover-border-color: var(--btn-secondary-bg-color); --btn-secondary-outline-font-weight: bold; --btn-primary-text-text-color: white; --btn-secondary-text-text-color: lightgrey;