From b83df3170122ec431ea26228fd4497d589509e41 Mon Sep 17 00:00:00 2001 From: majora2007 Date: Sat, 3 May 2025 19:47:26 +0000 Subject: [PATCH 01/57] Bump versions by dotnet-bump-version. --- Kavita.Common/Kavita.Common.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 628692006..79410fe0f 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -3,7 +3,7 @@ net9.0 kavitareader.com Kavita - 0.8.6.6 + 0.8.6.7 en true From 50a052e412cb49ec97f30661070d62ea6752d11b Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 3 May 2025 19:48:43 +0000 Subject: [PATCH 02/57] Update OpenAPI documentation --- openapi.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openapi.json b/openapi.json index cf26aeec8..58991a19e 100644 --- a/openapi.json +++ b/openapi.json @@ -1,8 +1,8 @@ { "openapi": "3.0.4", "info": { - "title": "Kavita (v0.8.6.5)", - "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.6.5", + "title": "Kavita (v0.8.6.6)", + "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.6.6", "license": { "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" From 3fe5933358d04afea7dd3a6dbaa94cd483d30cd1 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sat, 3 May 2025 17:40:11 -0500 Subject: [PATCH 03/57] Start of the metadata/filename on/off stuff. --- API.Tests/Services/ScannerServiceTests.cs | 5 +++++ API/Controllers/LibraryController.cs | 3 +++ API/DTOs/LibraryDto.cs | 10 ++++++++++ API/DTOs/UpdateLibraryDto.cs | 4 ++++ API/Entities/Library.cs | 10 ++++++++++ API/Helpers/Builders/LibraryBuilder.cs | 12 ++++++++++++ API/Services/Tasks/Scanner/Parser/BasicParser.cs | 1 + API/Services/Tasks/Scanner/ProcessSeries.cs | 5 +---- 8 files changed, 46 insertions(+), 4 deletions(-) diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 2e812647b..9b0271fc2 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -938,4 +938,9 @@ public class ScannerServiceTests : AbstractDbTest Assert.True(sortedChapters[1].SortOrder.Is(4f)); Assert.True(sortedChapters[2].SortOrder.Is(5f)); } + + #region Scanner Overhaul + + + #endregion } diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 2f12aa1fe..4f3b6c832 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -623,6 +623,9 @@ public class LibraryController : BaseApiController library.ManageReadingLists = dto.ManageReadingLists; library.AllowScrobbling = dto.AllowScrobbling; library.AllowMetadataMatching = dto.AllowMetadataMatching; + library.AllowFilenameParsing = dto.AllowFilenameParsing; + library.AllowMetadataParsing = dto.AllowMetadataParsing; + library.LibraryFileTypes = dto.FileGroupTypes .Select(t => new LibraryFileTypeGroup() {FileTypeGroup = t, LibraryId = library.Id}) .Distinct() diff --git a/API/DTOs/LibraryDto.cs b/API/DTOs/LibraryDto.cs index 18dea9434..7ddf36926 100644 --- a/API/DTOs/LibraryDto.cs +++ b/API/DTOs/LibraryDto.cs @@ -67,4 +67,14 @@ public class LibraryDto /// This does not exclude the library from being linked to wrt Series Relationships /// Requires a valid LicenseKey public bool AllowMetadataMatching { get; set; } = true; + /// + /// Allow Kavita to parse Metadata from Files based on Filename + /// + /// Cannot be false if is false + public bool AllowFilenameParsing { get; set; } = true; + /// + /// Allow Kavita to parse Metadata from files (ComicInfo/Epub/Pdf) + /// + /// Cannot be false if is false + public bool AllowMetadataParsing { get; set; } = true; } diff --git a/API/DTOs/UpdateLibraryDto.cs b/API/DTOs/UpdateLibraryDto.cs index de02f304d..118c2d6bc 100644 --- a/API/DTOs/UpdateLibraryDto.cs +++ b/API/DTOs/UpdateLibraryDto.cs @@ -28,6 +28,10 @@ public class UpdateLibraryDto public bool AllowScrobbling { get; init; } [Required] public bool AllowMetadataMatching { get; init; } + [Required] + public bool AllowFilenameParsing { get; set; } = true; + [Required] + public bool AllowMetadataParsing { get; set; } = true; /// /// What types of files to allow the scanner to pickup /// diff --git a/API/Entities/Library.cs b/API/Entities/Library.cs index abab81378..be69a2363 100644 --- a/API/Entities/Library.cs +++ b/API/Entities/Library.cs @@ -48,6 +48,16 @@ public class Library : IEntityDate, IHasCoverImage /// This does not exclude the library from being linked to wrt Series Relationships /// Requires a valid LicenseKey public bool AllowMetadataMatching { get; set; } = true; + /// + /// Allow Kavita to parse Metadata from Files based on Filename + /// + /// Cannot be false if is false + public bool AllowFilenameParsing { get; set; } = true; + /// + /// Allow Kavita to parse Metadata from files (ComicInfo/Epub/Pdf) + /// + /// Cannot be false if is false + public bool AllowMetadataParsing { get; set; } = true; public DateTime Created { get; set; } diff --git a/API/Helpers/Builders/LibraryBuilder.cs b/API/Helpers/Builders/LibraryBuilder.cs index 30e6136a5..3ebe8315a 100644 --- a/API/Helpers/Builders/LibraryBuilder.cs +++ b/API/Helpers/Builders/LibraryBuilder.cs @@ -115,4 +115,16 @@ public class LibraryBuilder : IEntityBuilder _library.AllowScrobbling = allowScrobbling; return this; } + + public LibraryBuilder WithAllowFilenameParsing(bool allow) + { + _library.AllowFilenameParsing = allow; + return this; + } + + public LibraryBuilder WithAllowMetadataParsing(bool allow) + { + _library.AllowMetadataParsing = allow; + return this; + } } diff --git a/API/Services/Tasks/Scanner/Parser/BasicParser.cs b/API/Services/Tasks/Scanner/Parser/BasicParser.cs index 1462ab3d3..154760e36 100644 --- a/API/Services/Tasks/Scanner/Parser/BasicParser.cs +++ b/API/Services/Tasks/Scanner/Parser/BasicParser.cs @@ -16,6 +16,7 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag { var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); // TODO: Potential Bug: This will return null, but on Image libraries, if all images, we would want to include this. + // NOTE: This may no longer be needed as we have file type group support now, thus an image wouldn't come for a Series if (type != LibraryType.Image && Parser.IsCoverImage(directoryService.FileSystem.Path.GetFileName(filePath))) return null; if (Parser.IsImage(filePath)) diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index 454c72733..336bbdba0 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -193,10 +193,6 @@ public class ProcessSeries : IProcessSeries if (seriesAdded) { - // See if any recommendations can link up to the series and pre-fetch external metadata for the series - // BackgroundJob.Enqueue(() => - // _externalMetadataService.FetchSeriesMetadata(series.Id, series.Library.Type)); - await _eventHub.SendMessageAsync(MessageFactory.SeriesAdded, MessageFactory.SeriesAddedEvent(series.Id, series.Name, series.LibraryId), false); } @@ -216,6 +212,7 @@ public class ProcessSeries : IProcessSeries if (seriesAdded) { + // Prefetch metadata if applicable await _externalMetadataService.FetchSeriesMetadata(series.Id, series.Library.Type); } await _metadataService.GenerateCoversForSeries(series.LibraryId, series.Id, false, false); From 5b8a643d82e7f453a511995a1c89201fc64eb8a3 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Sun, 4 May 2025 08:14:44 -0600 Subject: [PATCH 04/57] Small UI changes (#3787) --- API/DTOs/Account/AgeRestrictionDto.cs | 6 +- API/DTOs/Account/ConfirmEmailDto.cs | 2 +- API/DTOs/Account/ConfirmEmailUpdateDto.cs | 2 +- API/DTOs/Account/ConfirmMigrationEmailDto.cs | 2 +- API/DTOs/Account/ConfirmPasswordResetDto.cs | 2 +- API/DTOs/Account/InviteUserDto.cs | 2 +- API/DTOs/Account/InviteUserResponse.cs | 2 +- API/DTOs/Account/LoginDto.cs | 2 +- API/DTOs/Account/MigrateUserEmailDto.cs | 2 +- API/DTOs/Account/ResetPasswordDto.cs | 2 +- API/DTOs/Account/TokenRequestDto.cs | 2 +- API/DTOs/Account/UpdateAgeRestrictionDto.cs | 2 +- API/DTOs/Account/UpdateEmailDto.cs | 2 +- API/DTOs/Account/UpdateUserDto.cs | 10 +- API/DTOs/BulkActionDto.cs | 2 +- API/DTOs/ChapterDetailPlusDto.cs | 2 +- API/DTOs/ChapterDto.cs | 126 ++++++---------- API/DTOs/Collection/AppUserCollectionDto.cs | 30 ++-- .../CollectionTags/CollectionTagBulkAddDto.cs | 2 +- API/DTOs/CollectionTags/CollectionTagDto.cs | 8 +- .../CollectionTags/UpdateSeriesForTagDto.cs | 2 +- API/DTOs/ColorScape.cs | 2 +- API/DTOs/CopySettingsFromLibraryDto.cs | 2 +- API/DTOs/CoverDb/CoverDbAuthor.cs | 2 +- API/DTOs/CoverDb/CoverDbPeople.cs | 2 +- API/DTOs/CoverDb/CoverDbPersonIds.cs | 2 +- API/DTOs/Dashboard/DashboardStreamDto.cs | 2 +- API/DTOs/Dashboard/GroupedSeriesDto.cs | 2 +- API/DTOs/Dashboard/RecentlyAddedItemDto.cs | 2 +- API/DTOs/Dashboard/SmartFilterDto.cs | 2 +- .../UpdateDashboardStreamPositionDto.cs | 2 +- API/DTOs/Dashboard/UpdateStreamPositionDto.cs | 2 +- API/DTOs/DeleteChaptersDto.cs | 2 +- API/DTOs/DeleteSeriesDto.cs | 2 +- API/DTOs/Device/CreateDeviceDto.cs | 2 +- API/DTOs/Device/DeviceDto.cs | 2 +- API/DTOs/Device/SendSeriesToDeviceDto.cs | 2 +- API/DTOs/Device/SendToDeviceDto.cs | 2 +- API/DTOs/Device/UpdateDeviceDto.cs | 2 +- API/DTOs/Downloads/DownloadBookmarkDto.cs | 2 +- API/DTOs/Email/ConfirmationEmailDto.cs | 2 +- API/DTOs/Email/EmailHistoryDto.cs | 2 +- API/DTOs/Email/EmailMigrationDto.cs | 2 +- API/DTOs/Email/EmailTestResultDto.cs | 2 +- API/DTOs/Email/PasswordResetEmailDto.cs | 2 +- API/DTOs/Email/SendToDto.cs | 2 +- API/DTOs/Email/TestEmailDto.cs | 2 +- API/DTOs/Filtering/FilterDto.cs | 2 +- API/DTOs/Filtering/LanguageDto.cs | 2 +- API/DTOs/Filtering/Range.cs | 2 +- API/DTOs/Filtering/ReadStatus.cs | 2 +- API/DTOs/Filtering/SortOptions.cs | 2 +- API/DTOs/Filtering/v2/DecodeFilterDto.cs | 2 +- API/DTOs/Filtering/v2/FilterStatementDto.cs | 2 +- API/DTOs/Filtering/v2/FilterV2Dto.cs | 2 +- API/DTOs/Jobs/JobDto.cs | 2 +- API/DTOs/JumpBar/JumpKeyDto.cs | 2 +- API/DTOs/KavitaLocale.cs | 2 +- .../KavitaPlus/Account/AniListUpdateDto.cs | 2 +- API/DTOs/KavitaPlus/Account/UserTokenInfo.cs | 2 +- .../ExternalMetadataIdsDto.cs | 2 +- .../ExternalMetadata/MatchSeriesRequestDto.cs | 2 +- .../SeriesDetailPlusApiDto.cs | 3 +- .../KavitaPlus/License/EncryptLicenseDto.cs | 2 +- API/DTOs/KavitaPlus/License/LicenseInfoDto.cs | 2 +- .../KavitaPlus/License/LicenseValidDto.cs | 2 +- .../KavitaPlus/License/ResetLicenseDto.cs | 2 +- .../KavitaPlus/License/UpdateLicenseDto.cs | 2 +- .../KavitaPlus/Manage/ManageMatchFilterDto.cs | 2 +- .../KavitaPlus/Manage/ManageMatchSeriesDto.cs | 2 +- .../KavitaPlus/Metadata/ExternalChapterDto.cs | 2 +- .../Metadata/ExternalSeriesDetailDto.cs | 6 +- .../Metadata/MetadataFieldMappingDto.cs | 2 +- .../Metadata/MetadataSettingsDto.cs | 2 +- .../KavitaPlus/Metadata/SeriesCharacter.cs | 2 +- .../KavitaPlus/Metadata/SeriesRelationship.cs | 4 +- API/DTOs/LibraryDto.cs | 3 +- API/DTOs/MangaFileDto.cs | 2 +- API/DTOs/MediaErrors/MediaErrorDto.cs | 2 +- API/DTOs/MemberDto.cs | 2 +- API/DTOs/Metadata/AgeRatingDto.cs | 2 +- API/DTOs/Metadata/ChapterMetadataDto.cs | 2 +- API/DTOs/Metadata/GenreTagDto.cs | 2 +- .../Matching/ExternalSeriesMatchDto.cs | 3 +- API/DTOs/Metadata/Matching/MatchSeriesDto.cs | 2 +- API/DTOs/Metadata/PublicationStatusDto.cs | 2 +- API/DTOs/Metadata/TagDto.cs | 2 +- API/DTOs/OPDS/Feed.cs | 4 +- API/DTOs/OPDS/FeedAuthor.cs | 2 +- API/DTOs/OPDS/FeedCategory.cs | 2 +- API/DTOs/OPDS/FeedEntry.cs | 2 +- API/DTOs/OPDS/FeedEntryContent.cs | 2 +- API/DTOs/OPDS/FeedLink.cs | 2 +- API/DTOs/OPDS/OpenSearchDescription.cs | 2 +- API/DTOs/OPDS/SearchLink.cs | 2 +- API/DTOs/Person/UpdatePersonDto.cs | 2 +- API/DTOs/Progress/FullProgressDto.cs | 2 +- API/DTOs/Progress/ProgressDto.cs | 2 +- API/DTOs/RatingDto.cs | 8 +- API/DTOs/Reader/BookChapterItem.cs | 2 +- API/DTOs/Reader/BookInfoDto.cs | 2 +- API/DTOs/Reader/BookmarkDto.cs | 2 +- .../Reader/BulkRemoveBookmarkForSeriesDto.cs | 2 +- API/DTOs/Reader/ChapterInfoDto.cs | 2 +- API/DTOs/Reader/CreatePersonalToCDto.cs | 2 +- API/DTOs/Reader/FileDimensionDto.cs | 2 +- API/DTOs/Reader/HourEstimateRangeDto.cs | 2 +- .../Reader/MarkMultipleSeriesAsReadDto.cs | 2 +- API/DTOs/Reader/MarkReadDto.cs | 2 +- API/DTOs/Reader/MarkVolumeReadDto.cs | 2 +- API/DTOs/Reader/MarkVolumesReadDto.cs | 2 +- API/DTOs/Reader/PersonalToCDto.cs | 2 +- API/DTOs/Reader/RemoveBookmarkForSeriesDto.cs | 2 +- API/DTOs/ReadingLists/CBL/CblBook.cs | 2 +- API/DTOs/ReadingLists/CBL/CblConflictsDto.cs | 2 +- API/DTOs/ReadingLists/CBL/CblImportSummary.cs | 4 +- API/DTOs/ReadingLists/CBL/CblReadingList.cs | 4 +- API/DTOs/ReadingLists/CreateReadingListDto.cs | 2 +- .../ReadingLists/DeleteReadingListsDto.cs | 2 +- .../ReadingLists/PromoteReadingListsDto.cs | 2 +- API/DTOs/ReadingLists/ReadingListCast.cs | 2 +- API/DTOs/ReadingLists/ReadingListDto.cs | 6 +- API/DTOs/ReadingLists/ReadingListInfoDto.cs | 2 +- API/DTOs/ReadingLists/ReadingListItemDto.cs | 2 +- .../UpdateReadingListByChapterDto.cs | 2 +- .../UpdateReadingListByMultipleDto.cs | 2 +- .../UpdateReadingListByMultipleSeriesDto.cs | 2 +- .../UpdateReadingListBySeriesDto.cs | 2 +- .../UpdateReadingListByVolumeDto.cs | 2 +- API/DTOs/ReadingLists/UpdateReadingListDto.cs | 2 +- .../ReadingLists/UpdateReadingListPosition.cs | 2 +- API/DTOs/Recommendation/ExternalSeriesDto.cs | 2 +- API/DTOs/Recommendation/MetadataTagDto.cs | 2 +- API/DTOs/Recommendation/RecommendationDto.cs | 2 +- API/DTOs/Recommendation/SeriesStaffDto.cs | 2 +- API/DTOs/RefreshSeriesDto.cs | 2 +- API/DTOs/RegisterDto.cs | 2 +- API/DTOs/ScanFolderDto.cs | 2 +- API/DTOs/Scrobbling/MalUserInfoDto.cs | 2 +- API/DTOs/Scrobbling/MediaRecommendationDto.cs | 2 +- API/DTOs/Scrobbling/PlusSeriesDto.cs | 2 +- API/DTOs/Scrobbling/ScrobbleDto.cs | 2 +- API/DTOs/Scrobbling/ScrobbleErrorDto.cs | 2 +- API/DTOs/Scrobbling/ScrobbleEventDto.cs | 2 +- API/DTOs/Scrobbling/ScrobbleHoldDto.cs | 2 +- API/DTOs/Scrobbling/ScrobbleResponseDto.cs | 2 +- API/DTOs/Search/BookmarkSearchResultDto.cs | 2 +- API/DTOs/Search/SearchResultDto.cs | 2 +- API/DTOs/Search/SearchResultGroupDto.cs | 2 +- API/DTOs/SeriesByIdsDto.cs | 2 +- .../SeriesDetail/NextExpectedChapterDto.cs | 2 +- API/DTOs/SeriesDetail/RelatedSeriesDto.cs | 2 +- API/DTOs/SeriesDetail/SeriesDetailDto.cs | 2 +- API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs | 3 +- .../SeriesDetail/UpdateRelatedSeriesDto.cs | 2 +- API/DTOs/SeriesDetail/UpdateUserReviewDto.cs | 2 +- API/DTOs/SeriesDetail/UserReviewDto.cs | 2 +- API/DTOs/SeriesDto.cs | 51 +++---- API/DTOs/SeriesMetadataDto.cs | 2 +- API/DTOs/Settings/SMTPConfigDto.cs | 2 +- API/DTOs/Settings/ServerSettingDTO.cs | 2 +- .../BulkUpdateSideNavStreamVisibilityDto.cs | 2 +- API/DTOs/SideNav/ExternalSourceDto.cs | 2 +- API/DTOs/SideNav/SideNavStreamDto.cs | 2 +- API/DTOs/Statistics/Count.cs | 2 +- .../Statistics/FileExtensionBreakdownDto.cs | 4 +- API/DTOs/Statistics/PagesReadOnADayCount.cs | 2 +- API/DTOs/Statistics/ReadHistoryEvent.cs | 2 +- API/DTOs/Statistics/ServerStatisticsDto.cs | 2 +- API/DTOs/Statistics/TopReadsDto.cs | 2 +- API/DTOs/Statistics/UserReadStatistics.cs | 2 +- API/DTOs/Stats/FileExtensionExportDto.cs | 2 +- API/DTOs/Stats/ServerInfoSlimDto.cs | 2 +- API/DTOs/Stats/V3/LibraryStatV3.cs | 2 +- API/DTOs/Stats/V3/RelationshipStatV3.cs | 2 +- API/DTOs/Stats/V3/ServerInfoV3Dto.cs | 2 +- API/DTOs/Stats/V3/UserStatV3.cs | 2 +- API/DTOs/System/DirectoryDto.cs | 2 +- API/DTOs/Theme/ColorScapeDto.cs | 2 +- API/DTOs/Theme/DownloadableSiteThemeDto.cs | 2 +- API/DTOs/Theme/SiteThemeDto.cs | 2 +- API/DTOs/Theme/UpdateDefaultThemeDto.cs | 2 +- API/DTOs/Update/UpdateNotificationDto.cs | 2 +- API/DTOs/UpdateChapterDto.cs | 2 +- API/DTOs/UpdateLibraryDto.cs | 2 +- API/DTOs/UpdateLibraryForUserDto.cs | 2 +- API/DTOs/UpdateRBSDto.cs | 2 +- API/DTOs/UpdateRatingDto.cs | 2 +- API/DTOs/UpdateSeriesDto.cs | 2 +- API/DTOs/UpdateSeriesMetadataDto.cs | 2 +- API/DTOs/Uploads/UploadFileDto.cs | 2 +- API/DTOs/Uploads/UploadUrlDto.cs | 2 +- API/DTOs/UserDto.cs | 2 +- API/DTOs/UserPreferencesDto.cs | 142 ++++-------------- API/DTOs/VolumeDto.cs | 24 ++- API/DTOs/WantToRead/UpdateWantToReadDto.cs | 2 +- API/Data/Repositories/SeriesRepository.cs | 1 + API/Entities/Metadata/ExternalRating.cs | 3 + API/Services/Plus/WantToReadSyncService.cs | 1 + API/Startup.cs | 4 +- UI/Web/src/app/_services/action.service.ts | 2 +- .../series-detail.component.html | 2 +- UI/Web/src/assets/langs/en.json | 1 + 203 files changed, 369 insertions(+), 446 deletions(-) diff --git a/API/DTOs/Account/AgeRestrictionDto.cs b/API/DTOs/Account/AgeRestrictionDto.cs index 0aaec9b97..6505bdbff 100644 --- a/API/DTOs/Account/AgeRestrictionDto.cs +++ b/API/DTOs/Account/AgeRestrictionDto.cs @@ -2,15 +2,15 @@ namespace API.DTOs.Account; -public class AgeRestrictionDto +public sealed record AgeRestrictionDto { /// /// The maximum age rating a user has access to. -1 if not applicable /// - public required AgeRating AgeRating { get; set; } = AgeRating.NotApplicable; + public required AgeRating AgeRating { get; init; } = AgeRating.NotApplicable; /// /// Are Unknowns explicitly allowed against age rating /// /// Unknown is always lowest and default age rating. Setting this to false will ensure Teen age rating applies and unknowns are still filtered - public required bool IncludeUnknowns { get; set; } = false; + public required bool IncludeUnknowns { get; init; } = false; } diff --git a/API/DTOs/Account/ConfirmEmailDto.cs b/API/DTOs/Account/ConfirmEmailDto.cs index 2f5849e74..413f9f34a 100644 --- a/API/DTOs/Account/ConfirmEmailDto.cs +++ b/API/DTOs/Account/ConfirmEmailDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Account; -public class ConfirmEmailDto +public sealed record ConfirmEmailDto { [Required] public string Email { get; set; } = default!; diff --git a/API/DTOs/Account/ConfirmEmailUpdateDto.cs b/API/DTOs/Account/ConfirmEmailUpdateDto.cs index 42abb1295..2a0738e35 100644 --- a/API/DTOs/Account/ConfirmEmailUpdateDto.cs +++ b/API/DTOs/Account/ConfirmEmailUpdateDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Account; -public class ConfirmEmailUpdateDto +public sealed record ConfirmEmailUpdateDto { [Required] public string Email { get; set; } = default!; diff --git a/API/DTOs/Account/ConfirmMigrationEmailDto.cs b/API/DTOs/Account/ConfirmMigrationEmailDto.cs index efb42b8fd..cdfc1505c 100644 --- a/API/DTOs/Account/ConfirmMigrationEmailDto.cs +++ b/API/DTOs/Account/ConfirmMigrationEmailDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Account; -public class ConfirmMigrationEmailDto +public sealed record ConfirmMigrationEmailDto { public string Email { get; set; } = default!; public string Token { get; set; } = default!; diff --git a/API/DTOs/Account/ConfirmPasswordResetDto.cs b/API/DTOs/Account/ConfirmPasswordResetDto.cs index 16dd86f9a..00aff301b 100644 --- a/API/DTOs/Account/ConfirmPasswordResetDto.cs +++ b/API/DTOs/Account/ConfirmPasswordResetDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Account; -public class ConfirmPasswordResetDto +public sealed record ConfirmPasswordResetDto { [Required] public string Email { get; set; } = default!; diff --git a/API/DTOs/Account/InviteUserDto.cs b/API/DTOs/Account/InviteUserDto.cs index 112013053..c12bebc2b 100644 --- a/API/DTOs/Account/InviteUserDto.cs +++ b/API/DTOs/Account/InviteUserDto.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; namespace API.DTOs.Account; -public class InviteUserDto +public sealed record InviteUserDto { [Required] public string Email { get; set; } = default!; diff --git a/API/DTOs/Account/InviteUserResponse.cs b/API/DTOs/Account/InviteUserResponse.cs index a7e0d86ea..ed16bd05e 100644 --- a/API/DTOs/Account/InviteUserResponse.cs +++ b/API/DTOs/Account/InviteUserResponse.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Account; -public class InviteUserResponse +public sealed record InviteUserResponse { /// /// Email link used to setup the user account diff --git a/API/DTOs/Account/LoginDto.cs b/API/DTOs/Account/LoginDto.cs index fe8fce088..97338640b 100644 --- a/API/DTOs/Account/LoginDto.cs +++ b/API/DTOs/Account/LoginDto.cs @@ -1,7 +1,7 @@ namespace API.DTOs.Account; #nullable enable -public class LoginDto +public sealed record LoginDto { public string Username { get; init; } = default!; public string Password { get; set; } = default!; diff --git a/API/DTOs/Account/MigrateUserEmailDto.cs b/API/DTOs/Account/MigrateUserEmailDto.cs index 60d042165..4630c510f 100644 --- a/API/DTOs/Account/MigrateUserEmailDto.cs +++ b/API/DTOs/Account/MigrateUserEmailDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Account; -public class MigrateUserEmailDto +public sealed record MigrateUserEmailDto { public string Email { get; set; } = default!; public string Username { get; set; } = default!; diff --git a/API/DTOs/Account/ResetPasswordDto.cs b/API/DTOs/Account/ResetPasswordDto.cs index 51a195131..545ca5ba6 100644 --- a/API/DTOs/Account/ResetPasswordDto.cs +++ b/API/DTOs/Account/ResetPasswordDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Account; -public class ResetPasswordDto +public sealed record ResetPasswordDto { /// /// The Username of the User diff --git a/API/DTOs/Account/TokenRequestDto.cs b/API/DTOs/Account/TokenRequestDto.cs index 85ab9f87a..5c798721c 100644 --- a/API/DTOs/Account/TokenRequestDto.cs +++ b/API/DTOs/Account/TokenRequestDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Account; -public class TokenRequestDto +public sealed record TokenRequestDto { public string Token { get; init; } = default!; public string RefreshToken { get; init; } = default!; diff --git a/API/DTOs/Account/UpdateAgeRestrictionDto.cs b/API/DTOs/Account/UpdateAgeRestrictionDto.cs index ef6be1bba..2fa9c89d2 100644 --- a/API/DTOs/Account/UpdateAgeRestrictionDto.cs +++ b/API/DTOs/Account/UpdateAgeRestrictionDto.cs @@ -3,7 +3,7 @@ using API.Entities.Enums; namespace API.DTOs.Account; -public class UpdateAgeRestrictionDto +public sealed record UpdateAgeRestrictionDto { [Required] public AgeRating AgeRating { get; set; } diff --git a/API/DTOs/Account/UpdateEmailDto.cs b/API/DTOs/Account/UpdateEmailDto.cs index eac06be53..873862ba1 100644 --- a/API/DTOs/Account/UpdateEmailDto.cs +++ b/API/DTOs/Account/UpdateEmailDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Account; -public class UpdateEmailDto +public sealed record UpdateEmailDto { public string Email { get; set; } = default!; public string Password { get; set; } = default!; diff --git a/API/DTOs/Account/UpdateUserDto.cs b/API/DTOs/Account/UpdateUserDto.cs index c40124b7b..0cb0eaf66 100644 --- a/API/DTOs/Account/UpdateUserDto.cs +++ b/API/DTOs/Account/UpdateUserDto.cs @@ -4,12 +4,16 @@ using System.ComponentModel.DataAnnotations; namespace API.DTOs.Account; #nullable enable -public record UpdateUserDto +public sealed record UpdateUserDto { + /// public int UserId { get; set; } + /// public string Username { get; set; } = default!; + /// /// List of Roles to assign to user. If admin not present, Pleb will be applied. /// If admin present, all libraries will be granted access and will ignore those from DTO. + /// public IList Roles { get; init; } = default!; /// /// A list of libraries to grant access to @@ -19,8 +23,6 @@ public record UpdateUserDto /// An Age Rating which will limit the account to seeing everything equal to or below said rating. /// public AgeRestrictionDto AgeRestriction { get; init; } = default!; - /// - /// Email of the user - /// + /// public string? Email { get; set; } = default!; } diff --git a/API/DTOs/BulkActionDto.cs b/API/DTOs/BulkActionDto.cs index d3ce75293..c26a73e9c 100644 --- a/API/DTOs/BulkActionDto.cs +++ b/API/DTOs/BulkActionDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs; -public class BulkActionDto +public sealed record BulkActionDto { public List Ids { get; set; } /** diff --git a/API/DTOs/ChapterDetailPlusDto.cs b/API/DTOs/ChapterDetailPlusDto.cs index 9f9cfb8ab..d99482e55 100644 --- a/API/DTOs/ChapterDetailPlusDto.cs +++ b/API/DTOs/ChapterDetailPlusDto.cs @@ -4,7 +4,7 @@ using API.DTOs.SeriesDetail; namespace API.DTOs; -public class ChapterDetailPlusDto +public sealed record ChapterDetailPlusDto { public float Rating { get; set; } public bool HasBeenRated { get; set; } diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index 70c77e92d..70fb12e85 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -13,37 +13,24 @@ namespace API.DTOs; /// public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage { + /// public int Id { get; init; } - /// - /// Range of chapters. Chapter 2-4 -> "2-4". Chapter 2 -> "2". If special, will be special name. - /// - /// This can be something like 19.HU or Alpha as some comics are like this + /// public string Range { get; init; } = default!; - /// - /// Smallest number of the Range. - /// + /// [Obsolete("Use MinNumber and MaxNumber instead")] public string Number { get; init; } = default!; - /// - /// This may be 0 under the circumstance that the Issue is "Alpha" or other non-standard numbers. - /// + /// public float MinNumber { get; init; } + /// public float MaxNumber { get; init; } - /// - /// The sorting order of the Chapter. Inherits from MinNumber, but can be overridden. - /// + /// public float SortOrder { get; set; } - /// - /// Total number of pages in all MangaFiles - /// + /// public int Pages { get; init; } - /// - /// If this Chapter contains files that could only be identified as Series or has Special Identifier from filename - /// + /// public bool IsSpecial { get; init; } - /// - /// Used for books/specials to display custom title. For non-specials/books, will be set to - /// + /// public string Title { get; set; } = default!; /// /// The files that represent this Chapter @@ -61,46 +48,25 @@ public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage /// The last time a chapter was read by current authenticated user /// public DateTime LastReadingProgress { get; set; } - /// - /// If the Cover Image is locked for this entity - /// + /// public bool CoverImageLocked { get; set; } - /// - /// Volume Id this Chapter belongs to - /// + /// public int VolumeId { get; init; } - /// - /// When chapter was created - /// + /// public DateTime CreatedUtc { get; set; } + /// public DateTime LastModifiedUtc { get; set; } - /// - /// When chapter was created in local server time - /// - /// This is required for Tachiyomi Extension + /// public DateTime Created { get; set; } - /// - /// When the chapter was released. - /// - /// Metadata field + /// public DateTime ReleaseDate { get; init; } - /// - /// Title of the Chapter/Issue - /// - /// Metadata field + /// public string TitleName { get; set; } = default!; - /// - /// Summary of the Chapter - /// - /// This is not set normally, only for Series Detail + /// public string Summary { get; init; } = default!; - /// - /// Age Rating for the issue/chapter - /// + /// public AgeRating AgeRating { get; init; } - /// - /// Total words in a Chapter (books only) - /// + /// public long WordCount { get; set; } = 0L; /// /// Formatted Volume title ie) Volume 2. @@ -113,14 +79,9 @@ public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage public int MaxHoursToRead { get; set; } /// public float AvgHoursToRead { get; set; } - /// - /// Comma-separated link of urls to external services that have some relation to the Chapter - /// + /// public string WebLinks { get; set; } - /// - /// ISBN-13 (usually) of the Chapter - /// - /// This is guaranteed to be Valid + /// public string ISBN { get; set; } #region Metadata @@ -146,51 +107,60 @@ public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage /// public ICollection Tags { get; set; } = new List(); public PublicationStatus PublicationStatus { get; set; } - /// - /// Language for the Chapter/Issue - /// + /// public string? Language { get; set; } - /// - /// Number in the TotalCount of issues - /// + /// public int Count { get; set; } - /// - /// Total number of issues for the series - /// + /// public int TotalCount { get; set; } + /// public bool LanguageLocked { get; set; } + /// public bool SummaryLocked { get; set; } - /// - /// Locked by user so metadata updates from scan loop will not override AgeRating - /// + /// public bool AgeRatingLocked { get; set; } - /// - /// Locked by user so metadata updates from scan loop will not override PublicationStatus - /// public bool PublicationStatusLocked { get; set; } + /// public bool GenresLocked { get; set; } + /// public bool TagsLocked { get; set; } + /// public bool WriterLocked { get; set; } + /// public bool CharacterLocked { get; set; } + /// public bool ColoristLocked { get; set; } + /// public bool EditorLocked { get; set; } + /// public bool InkerLocked { get; set; } + /// public bool ImprintLocked { get; set; } + /// public bool LettererLocked { get; set; } + /// public bool PencillerLocked { get; set; } + /// public bool PublisherLocked { get; set; } + /// public bool TranslatorLocked { get; set; } + /// public bool TeamLocked { get; set; } + /// public bool LocationLocked { get; set; } + /// public bool CoverArtistLocked { get; set; } public bool ReleaseYearLocked { get; set; } #endregion - public string CoverImage { get; set; } - public string PrimaryColor { get; set; } = string.Empty; - public string SecondaryColor { get; set; } = string.Empty; + /// + public string? CoverImage { get; set; } + /// + public string? PrimaryColor { get; set; } = string.Empty; + /// + public string? SecondaryColor { get; set; } = string.Empty; public void ResetColorScape() { diff --git a/API/DTOs/Collection/AppUserCollectionDto.cs b/API/DTOs/Collection/AppUserCollectionDto.cs index ecfb5c062..0634b5d83 100644 --- a/API/DTOs/Collection/AppUserCollectionDto.cs +++ b/API/DTOs/Collection/AppUserCollectionDto.cs @@ -6,52 +6,52 @@ using API.Services.Plus; namespace API.DTOs.Collection; #nullable enable -public class AppUserCollectionDto : IHasCoverImage +public sealed record AppUserCollectionDto : IHasCoverImage { public int Id { get; init; } - public string Title { get; set; } = default!; - public string? Summary { get; set; } = default!; - public bool Promoted { get; set; } - public AgeRating AgeRating { get; set; } + public string Title { get; init; } = default!; + public string? Summary { get; init; } = default!; + public bool Promoted { get; init; } + public AgeRating AgeRating { get; init; } /// /// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set. /// public string? CoverImage { get; set; } = string.Empty; - public string PrimaryColor { get; set; } = string.Empty; - public string SecondaryColor { get; set; } = string.Empty; - public bool CoverImageLocked { get; set; } + public string? PrimaryColor { get; set; } = string.Empty; + public string? SecondaryColor { get; set; } = string.Empty; + public bool CoverImageLocked { get; init; } /// /// Number of Series in the Collection /// - public int ItemCount { get; set; } + public int ItemCount { get; init; } /// /// Owner of the Collection /// - public string? Owner { get; set; } + public string? Owner { get; init; } /// /// Last time Kavita Synced the Collection with an upstream source (for non Kavita sourced collections) /// - public DateTime LastSyncUtc { get; set; } + public DateTime LastSyncUtc { get; init; } /// /// Who created/manages the list. Non-Kavita lists are not editable by the user, except to promote /// - public ScrobbleProvider Source { get; set; } = ScrobbleProvider.Kavita; + public ScrobbleProvider Source { get; init; } = ScrobbleProvider.Kavita; /// /// For Non-Kavita sourced collections, the url to sync from /// - public string? SourceUrl { get; set; } + public string? SourceUrl { get; init; } /// /// Total number of items as of the last sync. Not applicable for Kavita managed collections. /// - public int TotalSourceCount { get; set; } + public int TotalSourceCount { get; init; } /// /// A
separated string of all missing series ///
- public string? MissingSeriesFromSource { get; set; } + public string? MissingSeriesFromSource { get; init; } public void ResetColorScape() { diff --git a/API/DTOs/CollectionTags/CollectionTagBulkAddDto.cs b/API/DTOs/CollectionTags/CollectionTagBulkAddDto.cs index 1d078959d..0a2270fbf 100644 --- a/API/DTOs/CollectionTags/CollectionTagBulkAddDto.cs +++ b/API/DTOs/CollectionTags/CollectionTagBulkAddDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.CollectionTags; -public class CollectionTagBulkAddDto +public sealed record CollectionTagBulkAddDto { /// /// Collection Tag Id diff --git a/API/DTOs/CollectionTags/CollectionTagDto.cs b/API/DTOs/CollectionTags/CollectionTagDto.cs index ec9939ebd..911622051 100644 --- a/API/DTOs/CollectionTags/CollectionTagDto.cs +++ b/API/DTOs/CollectionTags/CollectionTagDto.cs @@ -3,15 +3,21 @@ namespace API.DTOs.CollectionTags; [Obsolete("Use AppUserCollectionDto")] -public class CollectionTagDto +public sealed record CollectionTagDto { + /// public int Id { get; set; } + /// public string Title { get; set; } = default!; + /// public string Summary { get; set; } = default!; + /// public bool Promoted { get; set; } /// /// The cover image string. This is used on Frontend to show or hide the Cover Image /// + /// public string CoverImage { get; set; } = default!; + /// public bool CoverImageLocked { get; set; } } diff --git a/API/DTOs/CollectionTags/UpdateSeriesForTagDto.cs b/API/DTOs/CollectionTags/UpdateSeriesForTagDto.cs index 19e9a11e2..139834a60 100644 --- a/API/DTOs/CollectionTags/UpdateSeriesForTagDto.cs +++ b/API/DTOs/CollectionTags/UpdateSeriesForTagDto.cs @@ -4,7 +4,7 @@ using API.DTOs.Collection; namespace API.DTOs.CollectionTags; -public class UpdateSeriesForTagDto +public sealed record UpdateSeriesForTagDto { public AppUserCollectionDto Tag { get; init; } = default!; public IEnumerable SeriesIdsToRemove { get; init; } = default!; diff --git a/API/DTOs/ColorScape.cs b/API/DTOs/ColorScape.cs index d95346af7..5351f2351 100644 --- a/API/DTOs/ColorScape.cs +++ b/API/DTOs/ColorScape.cs @@ -4,7 +4,7 @@ /// /// A primary and secondary color /// -public class ColorScape +public sealed record ColorScape { public required string? Primary { get; set; } public required string? Secondary { get; set; } diff --git a/API/DTOs/CopySettingsFromLibraryDto.cs b/API/DTOs/CopySettingsFromLibraryDto.cs index ee75f7422..5ca5ead51 100644 --- a/API/DTOs/CopySettingsFromLibraryDto.cs +++ b/API/DTOs/CopySettingsFromLibraryDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs; -public class CopySettingsFromLibraryDto +public sealed record CopySettingsFromLibraryDto { public int SourceLibraryId { get; set; } public List TargetLibraryIds { get; set; } diff --git a/API/DTOs/CoverDb/CoverDbAuthor.cs b/API/DTOs/CoverDb/CoverDbAuthor.cs index 2f023398a..ca924801f 100644 --- a/API/DTOs/CoverDb/CoverDbAuthor.cs +++ b/API/DTOs/CoverDb/CoverDbAuthor.cs @@ -3,7 +3,7 @@ using YamlDotNet.Serialization; namespace API.DTOs.CoverDb; -public class CoverDbAuthor +public sealed record CoverDbAuthor { [YamlMember(Alias = "name", ApplyNamingConventions = false)] public string Name { get; set; } diff --git a/API/DTOs/CoverDb/CoverDbPeople.cs b/API/DTOs/CoverDb/CoverDbPeople.cs index c0f5e327e..2e825eac7 100644 --- a/API/DTOs/CoverDb/CoverDbPeople.cs +++ b/API/DTOs/CoverDb/CoverDbPeople.cs @@ -3,7 +3,7 @@ using YamlDotNet.Serialization; namespace API.DTOs.CoverDb; -public class CoverDbPeople +public sealed record CoverDbPeople { [YamlMember(Alias = "people", ApplyNamingConventions = false)] public List People { get; set; } = new List(); diff --git a/API/DTOs/CoverDb/CoverDbPersonIds.cs b/API/DTOs/CoverDb/CoverDbPersonIds.cs index 9c59415e6..5816bb479 100644 --- a/API/DTOs/CoverDb/CoverDbPersonIds.cs +++ b/API/DTOs/CoverDb/CoverDbPersonIds.cs @@ -3,7 +3,7 @@ namespace API.DTOs.CoverDb; #nullable enable -public class CoverDbPersonIds +public sealed record CoverDbPersonIds { [YamlMember(Alias = "hardcover_id", ApplyNamingConventions = false)] public string? HardcoverId { get; set; } = null; diff --git a/API/DTOs/Dashboard/DashboardStreamDto.cs b/API/DTOs/Dashboard/DashboardStreamDto.cs index 59e5f4f7d..297a706b1 100644 --- a/API/DTOs/Dashboard/DashboardStreamDto.cs +++ b/API/DTOs/Dashboard/DashboardStreamDto.cs @@ -4,7 +4,7 @@ using API.Entities.Enums; namespace API.DTOs.Dashboard; -public class DashboardStreamDto +public sealed record DashboardStreamDto { public int Id { get; set; } public required string Name { get; set; } diff --git a/API/DTOs/Dashboard/GroupedSeriesDto.cs b/API/DTOs/Dashboard/GroupedSeriesDto.cs index 3b283de34..940e42c40 100644 --- a/API/DTOs/Dashboard/GroupedSeriesDto.cs +++ b/API/DTOs/Dashboard/GroupedSeriesDto.cs @@ -5,7 +5,7 @@ namespace API.DTOs.Dashboard; /// /// This is a representation of a Series with some amount of underlying files within it. This is used for Recently Updated Series section /// -public class GroupedSeriesDto +public sealed record GroupedSeriesDto { public string SeriesName { get; set; } = default!; public int SeriesId { get; set; } diff --git a/API/DTOs/Dashboard/RecentlyAddedItemDto.cs b/API/DTOs/Dashboard/RecentlyAddedItemDto.cs index 2e5658e2e..bb0360b30 100644 --- a/API/DTOs/Dashboard/RecentlyAddedItemDto.cs +++ b/API/DTOs/Dashboard/RecentlyAddedItemDto.cs @@ -6,7 +6,7 @@ namespace API.DTOs.Dashboard; /// /// A mesh of data for Recently added volume/chapters /// -public class RecentlyAddedItemDto +public sealed record RecentlyAddedItemDto { public string SeriesName { get; set; } = default!; public int SeriesId { get; set; } diff --git a/API/DTOs/Dashboard/SmartFilterDto.cs b/API/DTOs/Dashboard/SmartFilterDto.cs index b23a74c69..c1bc4d7e1 100644 --- a/API/DTOs/Dashboard/SmartFilterDto.cs +++ b/API/DTOs/Dashboard/SmartFilterDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Dashboard; -public class SmartFilterDto +public sealed record SmartFilterDto { public int Id { get; set; } public required string Name { get; set; } diff --git a/API/DTOs/Dashboard/UpdateDashboardStreamPositionDto.cs b/API/DTOs/Dashboard/UpdateDashboardStreamPositionDto.cs index c2320f1a9..476a0732e 100644 --- a/API/DTOs/Dashboard/UpdateDashboardStreamPositionDto.cs +++ b/API/DTOs/Dashboard/UpdateDashboardStreamPositionDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Dashboard; -public class UpdateDashboardStreamPositionDto +public sealed record UpdateDashboardStreamPositionDto { public int FromPosition { get; set; } public int ToPosition { get; set; } diff --git a/API/DTOs/Dashboard/UpdateStreamPositionDto.cs b/API/DTOs/Dashboard/UpdateStreamPositionDto.cs index f9005a585..8de0ffa6f 100644 --- a/API/DTOs/Dashboard/UpdateStreamPositionDto.cs +++ b/API/DTOs/Dashboard/UpdateStreamPositionDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Dashboard; -public class UpdateStreamPositionDto +public sealed record UpdateStreamPositionDto { public int FromPosition { get; set; } public int ToPosition { get; set; } diff --git a/API/DTOs/DeleteChaptersDto.cs b/API/DTOs/DeleteChaptersDto.cs index cbd21df36..9fad2f1fb 100644 --- a/API/DTOs/DeleteChaptersDto.cs +++ b/API/DTOs/DeleteChaptersDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs; -public class DeleteChaptersDto +public sealed record DeleteChaptersDto { public IList ChapterIds { get; set; } = default!; } diff --git a/API/DTOs/DeleteSeriesDto.cs b/API/DTOs/DeleteSeriesDto.cs index 12687fc25..ec9ba0c68 100644 --- a/API/DTOs/DeleteSeriesDto.cs +++ b/API/DTOs/DeleteSeriesDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs; -public class DeleteSeriesDto +public sealed record DeleteSeriesDto { public IList SeriesIds { get; set; } = default!; } diff --git a/API/DTOs/Device/CreateDeviceDto.cs b/API/DTOs/Device/CreateDeviceDto.cs index 7e59483fa..a8fdb6bc9 100644 --- a/API/DTOs/Device/CreateDeviceDto.cs +++ b/API/DTOs/Device/CreateDeviceDto.cs @@ -3,7 +3,7 @@ using API.Entities.Enums.Device; namespace API.DTOs.Device; -public class CreateDeviceDto +public sealed record CreateDeviceDto { [Required] public string Name { get; set; } = default!; diff --git a/API/DTOs/Device/DeviceDto.cs b/API/DTOs/Device/DeviceDto.cs index b2e83e6fc..42140dcc1 100644 --- a/API/DTOs/Device/DeviceDto.cs +++ b/API/DTOs/Device/DeviceDto.cs @@ -6,7 +6,7 @@ namespace API.DTOs.Device; /// /// A Device is an entity that can receive data from Kavita (kindle) /// -public class DeviceDto +public sealed record DeviceDto { /// /// The device Id diff --git a/API/DTOs/Device/SendSeriesToDeviceDto.cs b/API/DTOs/Device/SendSeriesToDeviceDto.cs index a0a907464..58ce2293b 100644 --- a/API/DTOs/Device/SendSeriesToDeviceDto.cs +++ b/API/DTOs/Device/SendSeriesToDeviceDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Device; -public class SendSeriesToDeviceDto +public sealed record SendSeriesToDeviceDto { public int DeviceId { get; set; } public int SeriesId { get; set; } diff --git a/API/DTOs/Device/SendToDeviceDto.cs b/API/DTOs/Device/SendToDeviceDto.cs index fd88eaf59..a7a4dc0ff 100644 --- a/API/DTOs/Device/SendToDeviceDto.cs +++ b/API/DTOs/Device/SendToDeviceDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Device; -public class SendToDeviceDto +public sealed record SendToDeviceDto { public int DeviceId { get; set; } public IReadOnlyList ChapterIds { get; set; } = default!; diff --git a/API/DTOs/Device/UpdateDeviceDto.cs b/API/DTOs/Device/UpdateDeviceDto.cs index d28d372c3..2c3e72ea1 100644 --- a/API/DTOs/Device/UpdateDeviceDto.cs +++ b/API/DTOs/Device/UpdateDeviceDto.cs @@ -3,7 +3,7 @@ using API.Entities.Enums.Device; namespace API.DTOs.Device; -public class UpdateDeviceDto +public sealed record UpdateDeviceDto { [Required] public int Id { get; set; } diff --git a/API/DTOs/Downloads/DownloadBookmarkDto.cs b/API/DTOs/Downloads/DownloadBookmarkDto.cs index 5b7240b68..00f763dac 100644 --- a/API/DTOs/Downloads/DownloadBookmarkDto.cs +++ b/API/DTOs/Downloads/DownloadBookmarkDto.cs @@ -4,7 +4,7 @@ using API.DTOs.Reader; namespace API.DTOs.Downloads; -public class DownloadBookmarkDto +public sealed record DownloadBookmarkDto { [Required] public IEnumerable Bookmarks { get; set; } = default!; diff --git a/API/DTOs/Email/ConfirmationEmailDto.cs b/API/DTOs/Email/ConfirmationEmailDto.cs index 1a48c9974..197395794 100644 --- a/API/DTOs/Email/ConfirmationEmailDto.cs +++ b/API/DTOs/Email/ConfirmationEmailDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Email; -public class ConfirmationEmailDto +public sealed record ConfirmationEmailDto { public string InvitingUser { get; init; } = default!; public string EmailAddress { get; init; } = default!; diff --git a/API/DTOs/Email/EmailHistoryDto.cs b/API/DTOs/Email/EmailHistoryDto.cs index ca3549550..c2968d091 100644 --- a/API/DTOs/Email/EmailHistoryDto.cs +++ b/API/DTOs/Email/EmailHistoryDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Email; -public class EmailHistoryDto +public sealed record EmailHistoryDto { public long Id { get; set; } public bool Sent { get; set; } diff --git a/API/DTOs/Email/EmailMigrationDto.cs b/API/DTOs/Email/EmailMigrationDto.cs index f051e7337..5354afdaa 100644 --- a/API/DTOs/Email/EmailMigrationDto.cs +++ b/API/DTOs/Email/EmailMigrationDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Email; -public class EmailMigrationDto +public sealed record EmailMigrationDto { public string EmailAddress { get; init; } = default!; public string Username { get; init; } = default!; diff --git a/API/DTOs/Email/EmailTestResultDto.cs b/API/DTOs/Email/EmailTestResultDto.cs index 263e725c4..9be868eab 100644 --- a/API/DTOs/Email/EmailTestResultDto.cs +++ b/API/DTOs/Email/EmailTestResultDto.cs @@ -3,7 +3,7 @@ /// /// Represents if Test Email Service URL was successful or not and if any error occured /// -public class EmailTestResultDto +public sealed record EmailTestResultDto { public bool Successful { get; set; } public string ErrorMessage { get; set; } = default!; diff --git a/API/DTOs/Email/PasswordResetEmailDto.cs b/API/DTOs/Email/PasswordResetEmailDto.cs index 06abba171..9fda066a9 100644 --- a/API/DTOs/Email/PasswordResetEmailDto.cs +++ b/API/DTOs/Email/PasswordResetEmailDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Email; -public class PasswordResetEmailDto +public sealed record PasswordResetEmailDto { public string EmailAddress { get; init; } = default!; public string ServerConfirmationLink { get; init; } = default!; diff --git a/API/DTOs/Email/SendToDto.cs b/API/DTOs/Email/SendToDto.cs index 1261d110c..eacd29449 100644 --- a/API/DTOs/Email/SendToDto.cs +++ b/API/DTOs/Email/SendToDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Email; -public class SendToDto +public sealed record SendToDto { public string DestinationEmail { get; set; } = default!; public IEnumerable FilePaths { get; set; } = default!; diff --git a/API/DTOs/Email/TestEmailDto.cs b/API/DTOs/Email/TestEmailDto.cs index 37c12ed30..44c11bd6c 100644 --- a/API/DTOs/Email/TestEmailDto.cs +++ b/API/DTOs/Email/TestEmailDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Email; -public class TestEmailDto +public sealed record TestEmailDto { public string Url { get; set; } = default!; } diff --git a/API/DTOs/Filtering/FilterDto.cs b/API/DTOs/Filtering/FilterDto.cs index 9205a7bba..cb3374838 100644 --- a/API/DTOs/Filtering/FilterDto.cs +++ b/API/DTOs/Filtering/FilterDto.cs @@ -5,7 +5,7 @@ using API.Entities.Enums; namespace API.DTOs.Filtering; #nullable enable -public class FilterDto +public sealed record FilterDto { /// /// The type of Formats you want to be returned. An empty list will return all formats back diff --git a/API/DTOs/Filtering/LanguageDto.cs b/API/DTOs/Filtering/LanguageDto.cs index bc7ebb5cc..dde85f07e 100644 --- a/API/DTOs/Filtering/LanguageDto.cs +++ b/API/DTOs/Filtering/LanguageDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Filtering; -public class LanguageDto +public sealed record LanguageDto { public required string IsoCode { get; set; } public required string Title { get; set; } diff --git a/API/DTOs/Filtering/Range.cs b/API/DTOs/Filtering/Range.cs index a75164fa3..e697f26e1 100644 --- a/API/DTOs/Filtering/Range.cs +++ b/API/DTOs/Filtering/Range.cs @@ -4,7 +4,7 @@ /// /// Represents a range between two int/float/double /// -public class Range +public sealed record Range { public T? Min { get; init; } public T? Max { get; init; } diff --git a/API/DTOs/Filtering/ReadStatus.cs b/API/DTOs/Filtering/ReadStatus.cs index eeb786714..81498ecb5 100644 --- a/API/DTOs/Filtering/ReadStatus.cs +++ b/API/DTOs/Filtering/ReadStatus.cs @@ -3,7 +3,7 @@ /// /// Represents the Reading Status. This is a flag and allows multiple statues /// -public class ReadStatus +public sealed record ReadStatus { public bool NotRead { get; set; } = true; public bool InProgress { get; set; } = true; diff --git a/API/DTOs/Filtering/SortOptions.cs b/API/DTOs/Filtering/SortOptions.cs index 00bf91675..a08e2968e 100644 --- a/API/DTOs/Filtering/SortOptions.cs +++ b/API/DTOs/Filtering/SortOptions.cs @@ -3,7 +3,7 @@ /// /// Sorting Options for a query /// -public class SortOptions +public sealed record SortOptions { public SortField SortField { get; set; } public bool IsAscending { get; set; } = true; diff --git a/API/DTOs/Filtering/v2/DecodeFilterDto.cs b/API/DTOs/Filtering/v2/DecodeFilterDto.cs index 18dc166e7..db4c7ecce 100644 --- a/API/DTOs/Filtering/v2/DecodeFilterDto.cs +++ b/API/DTOs/Filtering/v2/DecodeFilterDto.cs @@ -3,7 +3,7 @@ /// /// For requesting an encoded filter to be decoded /// -public class DecodeFilterDto +public sealed record DecodeFilterDto { public string EncodedFilter { get; set; } } diff --git a/API/DTOs/Filtering/v2/FilterStatementDto.cs b/API/DTOs/Filtering/v2/FilterStatementDto.cs index a6192093e..ebe6d16af 100644 --- a/API/DTOs/Filtering/v2/FilterStatementDto.cs +++ b/API/DTOs/Filtering/v2/FilterStatementDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Filtering.v2; -public class FilterStatementDto +public sealed record FilterStatementDto { public FilterComparison Comparison { get; set; } public FilterField Field { get; set; } diff --git a/API/DTOs/Filtering/v2/FilterV2Dto.cs b/API/DTOs/Filtering/v2/FilterV2Dto.cs index 5bc50ff2f..11dc42a6b 100644 --- a/API/DTOs/Filtering/v2/FilterV2Dto.cs +++ b/API/DTOs/Filtering/v2/FilterV2Dto.cs @@ -6,7 +6,7 @@ namespace API.DTOs.Filtering.v2; /// /// Metadata filtering for v2 API only /// -public class FilterV2Dto +public sealed record FilterV2Dto { /// /// Not used in the UI. diff --git a/API/DTOs/Jobs/JobDto.cs b/API/DTOs/Jobs/JobDto.cs index 648765a34..55419811f 100644 --- a/API/DTOs/Jobs/JobDto.cs +++ b/API/DTOs/Jobs/JobDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Jobs; -public class JobDto +public sealed record JobDto { /// /// Job Id diff --git a/API/DTOs/JumpBar/JumpKeyDto.cs b/API/DTOs/JumpBar/JumpKeyDto.cs index 5a98a85ca..8dc5b4a8e 100644 --- a/API/DTOs/JumpBar/JumpKeyDto.cs +++ b/API/DTOs/JumpBar/JumpKeyDto.cs @@ -3,7 +3,7 @@ /// /// Represents an individual button in a Jump Bar /// -public class JumpKeyDto +public sealed record JumpKeyDto { /// /// Number of items in this Key diff --git a/API/DTOs/KavitaLocale.cs b/API/DTOs/KavitaLocale.cs index decfb7395..51868605f 100644 --- a/API/DTOs/KavitaLocale.cs +++ b/API/DTOs/KavitaLocale.cs @@ -1,6 +1,6 @@ namespace API.DTOs; -public class KavitaLocale +public sealed record KavitaLocale { public string FileName { get; set; } // Key public string RenderName { get; set; } diff --git a/API/DTOs/KavitaPlus/Account/AniListUpdateDto.cs b/API/DTOs/KavitaPlus/Account/AniListUpdateDto.cs index c6d2e07cc..c053bd34e 100644 --- a/API/DTOs/KavitaPlus/Account/AniListUpdateDto.cs +++ b/API/DTOs/KavitaPlus/Account/AniListUpdateDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.KavitaPlus.Account; -public class AniListUpdateDto +public sealed record AniListUpdateDto { public string Token { get; set; } } diff --git a/API/DTOs/KavitaPlus/Account/UserTokenInfo.cs b/API/DTOs/KavitaPlus/Account/UserTokenInfo.cs index 220bd9e7e..340ad0f4c 100644 --- a/API/DTOs/KavitaPlus/Account/UserTokenInfo.cs +++ b/API/DTOs/KavitaPlus/Account/UserTokenInfo.cs @@ -5,7 +5,7 @@ namespace API.DTOs.KavitaPlus.Account; /// /// Represents information around a user's tokens and their status /// -public class UserTokenInfo +public sealed record UserTokenInfo { public int UserId { get; set; } public string Username { get; set; } diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs index 547bb63a8..2b7dea8e6 100644 --- a/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs +++ b/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs @@ -6,7 +6,7 @@ namespace API.DTOs.KavitaPlus.ExternalMetadata; /// /// Used for matching and fetching metadata on a series /// -internal class ExternalMetadataIdsDto +internal sealed record ExternalMetadataIdsDto { public long? MalId { get; set; } public int? AniListId { get; set; } diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs index f63fe5a9e..6cd911700 100644 --- a/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs +++ b/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs @@ -4,7 +4,7 @@ using API.DTOs.Scrobbling; namespace API.DTOs.KavitaPlus.ExternalMetadata; #nullable enable -internal class MatchSeriesRequestDto +internal sealed record MatchSeriesRequestDto { public string SeriesName { get; set; } public ICollection AlternativeNames { get; set; } diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs index 26411bce7..d0cbb7bd3 100644 --- a/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs +++ b/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs @@ -1,11 +1,12 @@ using System.Collections.Generic; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Recommendation; using API.DTOs.Scrobbling; using API.DTOs.SeriesDetail; namespace API.DTOs.KavitaPlus.ExternalMetadata; -internal class SeriesDetailPlusApiDto +internal sealed record SeriesDetailPlusApiDto { public IEnumerable Recommendations { get; set; } public IEnumerable Reviews { get; set; } diff --git a/API/DTOs/KavitaPlus/License/EncryptLicenseDto.cs b/API/DTOs/KavitaPlus/License/EncryptLicenseDto.cs index eedbed2ef..dd85dd063 100644 --- a/API/DTOs/KavitaPlus/License/EncryptLicenseDto.cs +++ b/API/DTOs/KavitaPlus/License/EncryptLicenseDto.cs @@ -1,7 +1,7 @@ namespace API.DTOs.KavitaPlus.License; #nullable enable -public class EncryptLicenseDto +public sealed record EncryptLicenseDto { public required string License { get; set; } public required string InstallId { get; set; } diff --git a/API/DTOs/KavitaPlus/License/LicenseInfoDto.cs b/API/DTOs/KavitaPlus/License/LicenseInfoDto.cs index 398556aac..2cd9b5896 100644 --- a/API/DTOs/KavitaPlus/License/LicenseInfoDto.cs +++ b/API/DTOs/KavitaPlus/License/LicenseInfoDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.KavitaPlus.License; -public class LicenseInfoDto +public sealed record LicenseInfoDto { /// /// If cancelled, will represent cancellation date. If not, will represent repayment date diff --git a/API/DTOs/KavitaPlus/License/LicenseValidDto.cs b/API/DTOs/KavitaPlus/License/LicenseValidDto.cs index 56ee6cf73..a7bd476ce 100644 --- a/API/DTOs/KavitaPlus/License/LicenseValidDto.cs +++ b/API/DTOs/KavitaPlus/License/LicenseValidDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.KavitaPlus.License; -public class LicenseValidDto +public sealed record LicenseValidDto { public required string License { get; set; } public required string InstallId { get; set; } diff --git a/API/DTOs/KavitaPlus/License/ResetLicenseDto.cs b/API/DTOs/KavitaPlus/License/ResetLicenseDto.cs index 60496ee0e..d0fd9b666 100644 --- a/API/DTOs/KavitaPlus/License/ResetLicenseDto.cs +++ b/API/DTOs/KavitaPlus/License/ResetLicenseDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.KavitaPlus.License; -public class ResetLicenseDto +public sealed record ResetLicenseDto { public required string License { get; set; } public required string InstallId { get; set; } diff --git a/API/DTOs/KavitaPlus/License/UpdateLicenseDto.cs b/API/DTOs/KavitaPlus/License/UpdateLicenseDto.cs index 4621810f0..28b47efbe 100644 --- a/API/DTOs/KavitaPlus/License/UpdateLicenseDto.cs +++ b/API/DTOs/KavitaPlus/License/UpdateLicenseDto.cs @@ -1,7 +1,7 @@ namespace API.DTOs.KavitaPlus.License; #nullable enable -public class UpdateLicenseDto +public sealed record UpdateLicenseDto { /// /// License Key received from Kavita+ diff --git a/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs b/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs index 60bed32b0..8eb38c98a 100644 --- a/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs +++ b/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs @@ -12,7 +12,7 @@ public enum MatchStateOption DontMatch = 4 } -public class ManageMatchFilterDto +public sealed record ManageMatchFilterDto { public MatchStateOption MatchStateOption { get; set; } = MatchStateOption.All; public string SearchTerm { get; set; } = string.Empty; diff --git a/API/DTOs/KavitaPlus/Manage/ManageMatchSeriesDto.cs b/API/DTOs/KavitaPlus/Manage/ManageMatchSeriesDto.cs index 14617e7f0..a51e63ee9 100644 --- a/API/DTOs/KavitaPlus/Manage/ManageMatchSeriesDto.cs +++ b/API/DTOs/KavitaPlus/Manage/ManageMatchSeriesDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.KavitaPlus.Manage; -public class ManageMatchSeriesDto +public sealed record ManageMatchSeriesDto { public SeriesDto Series { get; set; } public bool IsMatched { get; set; } diff --git a/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs b/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs index 6b711513c..1dcd8494c 100644 --- a/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs +++ b/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs @@ -7,7 +7,7 @@ namespace API.DTOs.KavitaPlus.Metadata; /// /// Information about an individual issue/chapter/book from Kavita+ /// -public class ExternalChapterDto +public sealed record ExternalChapterDto { public string Title { get; set; } diff --git a/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs b/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs index 2ea746214..a3cd378b2 100644 --- a/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs +++ b/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs @@ -1,16 +1,16 @@ using System; using System.Collections.Generic; -using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Recommendation; using API.DTOs.Scrobbling; using API.Services.Plus; -namespace API.DTOs.Recommendation; +namespace API.DTOs.KavitaPlus.Metadata; #nullable enable /// /// This is AniListSeries /// -public class ExternalSeriesDetailDto +public sealed record ExternalSeriesDetailDto { public string Name { get; set; } public int? AniListId { get; set; } diff --git a/API/DTOs/KavitaPlus/Metadata/MetadataFieldMappingDto.cs b/API/DTOs/KavitaPlus/Metadata/MetadataFieldMappingDto.cs index 796cfeb1a..a9debabd1 100644 --- a/API/DTOs/KavitaPlus/Metadata/MetadataFieldMappingDto.cs +++ b/API/DTOs/KavitaPlus/Metadata/MetadataFieldMappingDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.KavitaPlus.Metadata; -public class MetadataFieldMappingDto +public sealed record MetadataFieldMappingDto { public int Id { get; set; } public MetadataFieldType SourceType { get; set; } diff --git a/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs b/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs index 1dd26a7bc..e9f6614bc 100644 --- a/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs +++ b/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs @@ -7,7 +7,7 @@ using NotImplementedException = System.NotImplementedException; namespace API.DTOs.KavitaPlus.Metadata; -public class MetadataSettingsDto +public sealed record MetadataSettingsDto { /// /// If writing any sort of metadata from upstream (AniList, Hardcover) source is allowed diff --git a/API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs b/API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs index bb5a3f20a..2b57548cd 100644 --- a/API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs +++ b/API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs @@ -9,7 +9,7 @@ public enum CharacterRole } -public class SeriesCharacter +public sealed record SeriesCharacter { public string Name { get; set; } public required string Description { get; set; } diff --git a/API/DTOs/KavitaPlus/Metadata/SeriesRelationship.cs b/API/DTOs/KavitaPlus/Metadata/SeriesRelationship.cs index bd42e73a1..0b1f619a2 100644 --- a/API/DTOs/KavitaPlus/Metadata/SeriesRelationship.cs +++ b/API/DTOs/KavitaPlus/Metadata/SeriesRelationship.cs @@ -5,7 +5,7 @@ using API.Services.Plus; namespace API.DTOs.KavitaPlus.Metadata; -public class ALMediaTitle +public sealed record ALMediaTitle { public string? EnglishTitle { get; set; } public string RomajiTitle { get; set; } @@ -13,7 +13,7 @@ public class ALMediaTitle public string PreferredTitle { get; set; } } -public class SeriesRelationship +public sealed record SeriesRelationship { public int AniListId { get; set; } public int? MalId { get; set; } diff --git a/API/DTOs/LibraryDto.cs b/API/DTOs/LibraryDto.cs index 18dea9434..8ba687346 100644 --- a/API/DTOs/LibraryDto.cs +++ b/API/DTOs/LibraryDto.cs @@ -1,12 +1,11 @@ using System; -using System.Collections; using System.Collections.Generic; using API.Entities.Enums; namespace API.DTOs; #nullable enable -public class LibraryDto +public sealed record LibraryDto { public int Id { get; init; } public string? Name { get; init; } diff --git a/API/DTOs/MangaFileDto.cs b/API/DTOs/MangaFileDto.cs index 9f2f19a42..23bb37467 100644 --- a/API/DTOs/MangaFileDto.cs +++ b/API/DTOs/MangaFileDto.cs @@ -4,7 +4,7 @@ using API.Entities.Enums; namespace API.DTOs; #nullable enable -public class MangaFileDto +public sealed record MangaFileDto { public int Id { get; init; } /// diff --git a/API/DTOs/MediaErrors/MediaErrorDto.cs b/API/DTOs/MediaErrors/MediaErrorDto.cs index bfaf57124..b77ee88be 100644 --- a/API/DTOs/MediaErrors/MediaErrorDto.cs +++ b/API/DTOs/MediaErrors/MediaErrorDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.MediaErrors; -public class MediaErrorDto +public sealed record MediaErrorDto { /// /// Format Type (RAR, ZIP, 7Zip, Epub, PDF) diff --git a/API/DTOs/MemberDto.cs b/API/DTOs/MemberDto.cs index 7b750b32f..f5f24b284 100644 --- a/API/DTOs/MemberDto.cs +++ b/API/DTOs/MemberDto.cs @@ -8,7 +8,7 @@ namespace API.DTOs; /// /// Represents a member of a Kavita server. /// -public class MemberDto +public sealed record MemberDto { public int Id { get; init; } public string? Username { get; init; } diff --git a/API/DTOs/Metadata/AgeRatingDto.cs b/API/DTOs/Metadata/AgeRatingDto.cs index 07523c3fe..bfa835ef5 100644 --- a/API/DTOs/Metadata/AgeRatingDto.cs +++ b/API/DTOs/Metadata/AgeRatingDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Metadata; -public class AgeRatingDto +public sealed record AgeRatingDto { public AgeRating Value { get; set; } public required string Title { get; set; } diff --git a/API/DTOs/Metadata/ChapterMetadataDto.cs b/API/DTOs/Metadata/ChapterMetadataDto.cs index bbd93d618..1adc52cd1 100644 --- a/API/DTOs/Metadata/ChapterMetadataDto.cs +++ b/API/DTOs/Metadata/ChapterMetadataDto.cs @@ -9,7 +9,7 @@ namespace API.DTOs.Metadata; /// Exclusively metadata about a given chapter /// [Obsolete("Will not be maintained as of v0.8.1")] -public class ChapterMetadataDto +public sealed record ChapterMetadataDto { public int Id { get; set; } public int ChapterId { get; set; } diff --git a/API/DTOs/Metadata/GenreTagDto.cs b/API/DTOs/Metadata/GenreTagDto.cs index cf05ebbff..4846048d2 100644 --- a/API/DTOs/Metadata/GenreTagDto.cs +++ b/API/DTOs/Metadata/GenreTagDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Metadata; -public class GenreTagDto +public sealed record GenreTagDto { public int Id { get; set; } public required string Title { get; set; } diff --git a/API/DTOs/Metadata/Matching/ExternalSeriesMatchDto.cs b/API/DTOs/Metadata/Matching/ExternalSeriesMatchDto.cs index aefd697ba..774581b37 100644 --- a/API/DTOs/Metadata/Matching/ExternalSeriesMatchDto.cs +++ b/API/DTOs/Metadata/Matching/ExternalSeriesMatchDto.cs @@ -1,8 +1,9 @@ +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Recommendation; namespace API.DTOs.Metadata.Matching; -public class ExternalSeriesMatchDto +public sealed record ExternalSeriesMatchDto { public ExternalSeriesDetailDto Series { get; set; } public float MatchRating { get; set; } diff --git a/API/DTOs/Metadata/Matching/MatchSeriesDto.cs b/API/DTOs/Metadata/Matching/MatchSeriesDto.cs index 1f401e787..bb497b9ab 100644 --- a/API/DTOs/Metadata/Matching/MatchSeriesDto.cs +++ b/API/DTOs/Metadata/Matching/MatchSeriesDto.cs @@ -3,7 +3,7 @@ namespace API.DTOs.Metadata.Matching; /// /// Used for matching a series with Kavita+ for metadata and scrobbling /// -public class MatchSeriesDto +public sealed record MatchSeriesDto { /// /// When set, Kavita will stop attempting to match this series and will not perform any scrobbling diff --git a/API/DTOs/Metadata/PublicationStatusDto.cs b/API/DTOs/Metadata/PublicationStatusDto.cs index b8166a6e5..b4f12500a 100644 --- a/API/DTOs/Metadata/PublicationStatusDto.cs +++ b/API/DTOs/Metadata/PublicationStatusDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Metadata; -public class PublicationStatusDto +public sealed record PublicationStatusDto { public PublicationStatus Value { get; set; } public required string Title { get; set; } diff --git a/API/DTOs/Metadata/TagDto.cs b/API/DTOs/Metadata/TagDto.cs index 59e03a279..f8deb6913 100644 --- a/API/DTOs/Metadata/TagDto.cs +++ b/API/DTOs/Metadata/TagDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Metadata; -public class TagDto +public sealed record TagDto { public int Id { get; set; } public required string Title { get; set; } diff --git a/API/DTOs/OPDS/Feed.cs b/API/DTOs/OPDS/Feed.cs index 76a740b89..5f4c4b115 100644 --- a/API/DTOs/OPDS/Feed.cs +++ b/API/DTOs/OPDS/Feed.cs @@ -4,11 +4,13 @@ using System.Xml.Serialization; namespace API.DTOs.OPDS; +// TODO: OPDS Dtos are internal state, shouldn't be in DTO directory + /// /// /// [XmlRoot("feed", Namespace = "http://www.w3.org/2005/Atom")] -public class Feed +public sealed record Feed { [XmlElement("updated")] public string Updated { get; init; } = DateTime.UtcNow.ToString("s"); diff --git a/API/DTOs/OPDS/FeedAuthor.cs b/API/DTOs/OPDS/FeedAuthor.cs index 1fd3e6cd2..4196997dd 100644 --- a/API/DTOs/OPDS/FeedAuthor.cs +++ b/API/DTOs/OPDS/FeedAuthor.cs @@ -2,7 +2,7 @@ namespace API.DTOs.OPDS; -public class FeedAuthor +public sealed record FeedAuthor { [XmlElement("name")] public string Name { get; set; } diff --git a/API/DTOs/OPDS/FeedCategory.cs b/API/DTOs/OPDS/FeedCategory.cs index 3129fab60..2352b4af2 100644 --- a/API/DTOs/OPDS/FeedCategory.cs +++ b/API/DTOs/OPDS/FeedCategory.cs @@ -2,7 +2,7 @@ namespace API.DTOs.OPDS; -public class FeedCategory +public sealed record FeedCategory { [XmlAttribute("scheme")] public string Scheme { get; } = "http://www.bisg.org/standards/bisac_subject/index.html"; diff --git a/API/DTOs/OPDS/FeedEntry.cs b/API/DTOs/OPDS/FeedEntry.cs index da8b53b74..838ebd124 100644 --- a/API/DTOs/OPDS/FeedEntry.cs +++ b/API/DTOs/OPDS/FeedEntry.cs @@ -5,7 +5,7 @@ using System.Xml.Serialization; namespace API.DTOs.OPDS; #nullable enable -public class FeedEntry +public sealed record FeedEntry { [XmlElement("updated")] public string Updated { get; init; } = DateTime.UtcNow.ToString("s"); diff --git a/API/DTOs/OPDS/FeedEntryContent.cs b/API/DTOs/OPDS/FeedEntryContent.cs index 3e95ce643..4de9b73bd 100644 --- a/API/DTOs/OPDS/FeedEntryContent.cs +++ b/API/DTOs/OPDS/FeedEntryContent.cs @@ -2,7 +2,7 @@ namespace API.DTOs.OPDS; -public class FeedEntryContent +public sealed record FeedEntryContent { [XmlAttribute("type")] public string Type = "text"; diff --git a/API/DTOs/OPDS/FeedLink.cs b/API/DTOs/OPDS/FeedLink.cs index cff3b6736..28c55bbe8 100644 --- a/API/DTOs/OPDS/FeedLink.cs +++ b/API/DTOs/OPDS/FeedLink.cs @@ -3,7 +3,7 @@ using System.Xml.Serialization; namespace API.DTOs.OPDS; -public class FeedLink +public sealed record FeedLink { [XmlIgnore] public bool IsPageStream { get; set; } diff --git a/API/DTOs/OPDS/OpenSearchDescription.cs b/API/DTOs/OPDS/OpenSearchDescription.cs index cc8392a88..eba26572f 100644 --- a/API/DTOs/OPDS/OpenSearchDescription.cs +++ b/API/DTOs/OPDS/OpenSearchDescription.cs @@ -3,7 +3,7 @@ namespace API.DTOs.OPDS; [XmlRoot("OpenSearchDescription", Namespace = "http://a9.com/-/spec/opensearch/1.1/")] -public class OpenSearchDescription +public sealed record OpenSearchDescription { /// /// Contains a brief human-readable title that identifies this search engine. diff --git a/API/DTOs/OPDS/SearchLink.cs b/API/DTOs/OPDS/SearchLink.cs index dba67f3bd..b4698c221 100644 --- a/API/DTOs/OPDS/SearchLink.cs +++ b/API/DTOs/OPDS/SearchLink.cs @@ -2,7 +2,7 @@ namespace API.DTOs.OPDS; -public class SearchLink +public sealed record SearchLink { [XmlAttribute("type")] public string Type { get; set; } = default!; diff --git a/API/DTOs/Person/UpdatePersonDto.cs b/API/DTOs/Person/UpdatePersonDto.cs index d21fb7350..29190151f 100644 --- a/API/DTOs/Person/UpdatePersonDto.cs +++ b/API/DTOs/Person/UpdatePersonDto.cs @@ -3,7 +3,7 @@ namespace API.DTOs; #nullable enable -public class UpdatePersonDto +public sealed record UpdatePersonDto { [Required] public int Id { get; init; } diff --git a/API/DTOs/Progress/FullProgressDto.cs b/API/DTOs/Progress/FullProgressDto.cs index 7d0b47f60..4f97ab44a 100644 --- a/API/DTOs/Progress/FullProgressDto.cs +++ b/API/DTOs/Progress/FullProgressDto.cs @@ -5,7 +5,7 @@ namespace API.DTOs.Progress; /// /// A full progress Record from the DB (not all data, only what's needed for API) /// -public class FullProgressDto +public sealed record FullProgressDto { public int Id { get; set; } public int ChapterId { get; set; } diff --git a/API/DTOs/Progress/ProgressDto.cs b/API/DTOs/Progress/ProgressDto.cs index 9fc9010aa..0add848c5 100644 --- a/API/DTOs/Progress/ProgressDto.cs +++ b/API/DTOs/Progress/ProgressDto.cs @@ -4,7 +4,7 @@ using System.ComponentModel.DataAnnotations; namespace API.DTOs.Progress; #nullable enable -public class ProgressDto +public sealed record ProgressDto { [Required] public int VolumeId { get; set; } diff --git a/API/DTOs/RatingDto.cs b/API/DTOs/RatingDto.cs index 264d2d43c..101aa7ac5 100644 --- a/API/DTOs/RatingDto.cs +++ b/API/DTOs/RatingDto.cs @@ -1,14 +1,18 @@ -using API.Entities.Enums; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Metadata; using API.Services.Plus; namespace API.DTOs; #nullable enable -public class RatingDto +public sealed record RatingDto { + public int AverageScore { get; set; } public int FavoriteCount { get; set; } public ScrobbleProvider Provider { get; set; } + /// public RatingAuthority Authority { get; set; } = RatingAuthority.User; public string? ProviderUrl { get; set; } } diff --git a/API/DTOs/Reader/BookChapterItem.cs b/API/DTOs/Reader/BookChapterItem.cs index dcfb7b904..892e82e27 100644 --- a/API/DTOs/Reader/BookChapterItem.cs +++ b/API/DTOs/Reader/BookChapterItem.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Reader; -public class BookChapterItem +public sealed record BookChapterItem { /// /// Name of the Chapter diff --git a/API/DTOs/Reader/BookInfoDto.cs b/API/DTOs/Reader/BookInfoDto.cs index c379f71f8..2473cd5dc 100644 --- a/API/DTOs/Reader/BookInfoDto.cs +++ b/API/DTOs/Reader/BookInfoDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Reader; -public class BookInfoDto : IChapterInfoDto +public sealed record BookInfoDto : IChapterInfoDto { public string BookTitle { get; set; } = default! ; public int SeriesId { get; set; } diff --git a/API/DTOs/Reader/BookmarkDto.cs b/API/DTOs/Reader/BookmarkDto.cs index ef4cf3d6d..da18fc28e 100644 --- a/API/DTOs/Reader/BookmarkDto.cs +++ b/API/DTOs/Reader/BookmarkDto.cs @@ -3,7 +3,7 @@ namespace API.DTOs.Reader; #nullable enable -public class BookmarkDto +public sealed record BookmarkDto { public int Id { get; set; } [Required] diff --git a/API/DTOs/Reader/BulkRemoveBookmarkForSeriesDto.cs b/API/DTOs/Reader/BulkRemoveBookmarkForSeriesDto.cs index 7490f837c..51ccf5cc3 100644 --- a/API/DTOs/Reader/BulkRemoveBookmarkForSeriesDto.cs +++ b/API/DTOs/Reader/BulkRemoveBookmarkForSeriesDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Reader; -public class BulkRemoveBookmarkForSeriesDto +public sealed record BulkRemoveBookmarkForSeriesDto { public ICollection SeriesIds { get; init; } = default!; } diff --git a/API/DTOs/Reader/ChapterInfoDto.cs b/API/DTOs/Reader/ChapterInfoDto.cs index 4584a5830..4da08a31d 100644 --- a/API/DTOs/Reader/ChapterInfoDto.cs +++ b/API/DTOs/Reader/ChapterInfoDto.cs @@ -7,7 +7,7 @@ namespace API.DTOs.Reader; /// /// Information about the Chapter for the Reader to render /// -public class ChapterInfoDto : IChapterInfoDto +public sealed record ChapterInfoDto : IChapterInfoDto { /// /// The Chapter Number diff --git a/API/DTOs/Reader/CreatePersonalToCDto.cs b/API/DTOs/Reader/CreatePersonalToCDto.cs index 3b80ece4a..95272ca58 100644 --- a/API/DTOs/Reader/CreatePersonalToCDto.cs +++ b/API/DTOs/Reader/CreatePersonalToCDto.cs @@ -1,7 +1,7 @@ namespace API.DTOs.Reader; #nullable enable -public class CreatePersonalToCDto +public sealed record CreatePersonalToCDto { public required int ChapterId { get; set; } public required int VolumeId { get; set; } diff --git a/API/DTOs/Reader/FileDimensionDto.cs b/API/DTOs/Reader/FileDimensionDto.cs index baee20dd0..7a7d2978f 100644 --- a/API/DTOs/Reader/FileDimensionDto.cs +++ b/API/DTOs/Reader/FileDimensionDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Reader; -public class FileDimensionDto +public sealed record FileDimensionDto { public int Width { get; set; } public int Height { get; set; } diff --git a/API/DTOs/Reader/HourEstimateRangeDto.cs b/API/DTOs/Reader/HourEstimateRangeDto.cs index 8c8bd11a9..3facf8e56 100644 --- a/API/DTOs/Reader/HourEstimateRangeDto.cs +++ b/API/DTOs/Reader/HourEstimateRangeDto.cs @@ -3,7 +3,7 @@ /// /// A range of time to read a selection (series, chapter, etc) /// -public record HourEstimateRangeDto +public sealed record HourEstimateRangeDto { /// /// Min hours to read the selection diff --git a/API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs b/API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs index 50187ec81..4c39f7d76 100644 --- a/API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs +++ b/API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Reader; -public class MarkMultipleSeriesAsReadDto +public sealed record MarkMultipleSeriesAsReadDto { public IReadOnlyList SeriesIds { get; init; } = default!; } diff --git a/API/DTOs/Reader/MarkReadDto.cs b/API/DTOs/Reader/MarkReadDto.cs index 9bf46a6d5..c6f7367c0 100644 --- a/API/DTOs/Reader/MarkReadDto.cs +++ b/API/DTOs/Reader/MarkReadDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Reader; -public class MarkReadDto +public sealed record MarkReadDto { public int SeriesId { get; init; } } diff --git a/API/DTOs/Reader/MarkVolumeReadDto.cs b/API/DTOs/Reader/MarkVolumeReadDto.cs index 47ffd2649..be95d2e98 100644 --- a/API/DTOs/Reader/MarkVolumeReadDto.cs +++ b/API/DTOs/Reader/MarkVolumeReadDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Reader; -public class MarkVolumeReadDto +public sealed record MarkVolumeReadDto { public int SeriesId { get; init; } public int VolumeId { get; init; } diff --git a/API/DTOs/Reader/MarkVolumesReadDto.cs b/API/DTOs/Reader/MarkVolumesReadDto.cs index ebe1cd76c..b07bfbc67 100644 --- a/API/DTOs/Reader/MarkVolumesReadDto.cs +++ b/API/DTOs/Reader/MarkVolumesReadDto.cs @@ -5,7 +5,7 @@ namespace API.DTOs.Reader; /// /// This is used for bulk updating a set of volume and or chapters in one go /// -public class MarkVolumesReadDto +public sealed record MarkVolumesReadDto { public int SeriesId { get; set; } /// diff --git a/API/DTOs/Reader/PersonalToCDto.cs b/API/DTOs/Reader/PersonalToCDto.cs index 144ed561f..c979d9d78 100644 --- a/API/DTOs/Reader/PersonalToCDto.cs +++ b/API/DTOs/Reader/PersonalToCDto.cs @@ -2,7 +2,7 @@ #nullable enable -public class PersonalToCDto +public sealed record PersonalToCDto { public required int ChapterId { get; set; } public required int PageNumber { get; set; } diff --git a/API/DTOs/Reader/RemoveBookmarkForSeriesDto.cs b/API/DTOs/Reader/RemoveBookmarkForSeriesDto.cs index ed6368a4f..ecbb744c8 100644 --- a/API/DTOs/Reader/RemoveBookmarkForSeriesDto.cs +++ b/API/DTOs/Reader/RemoveBookmarkForSeriesDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Reader; -public class RemoveBookmarkForSeriesDto +public sealed record RemoveBookmarkForSeriesDto { public int SeriesId { get; init; } } diff --git a/API/DTOs/ReadingLists/CBL/CblBook.cs b/API/DTOs/ReadingLists/CBL/CblBook.cs index 08930e208..d51795b8d 100644 --- a/API/DTOs/ReadingLists/CBL/CblBook.cs +++ b/API/DTOs/ReadingLists/CBL/CblBook.cs @@ -5,7 +5,7 @@ namespace API.DTOs.ReadingLists.CBL; [XmlRoot(ElementName="Book")] -public class CblBook +public sealed record CblBook { [XmlAttribute("Series")] public string Series { get; set; } diff --git a/API/DTOs/ReadingLists/CBL/CblConflictsDto.cs b/API/DTOs/ReadingLists/CBL/CblConflictsDto.cs index 70a002884..35234923f 100644 --- a/API/DTOs/ReadingLists/CBL/CblConflictsDto.cs +++ b/API/DTOs/ReadingLists/CBL/CblConflictsDto.cs @@ -3,7 +3,7 @@ namespace API.DTOs.ReadingLists.CBL; -public class CblConflictQuestion +public sealed record CblConflictQuestion { public string SeriesName { get; set; } public IList LibrariesIds { get; set; } diff --git a/API/DTOs/ReadingLists/CBL/CblImportSummary.cs b/API/DTOs/ReadingLists/CBL/CblImportSummary.cs index 136a31aa8..b9716421e 100644 --- a/API/DTOs/ReadingLists/CBL/CblImportSummary.cs +++ b/API/DTOs/ReadingLists/CBL/CblImportSummary.cs @@ -75,7 +75,7 @@ public enum CblImportReason InvalidFile = 9, } -public class CblBookResult +public sealed record CblBookResult { /// /// Order in the CBL @@ -114,7 +114,7 @@ public class CblBookResult /// /// Represents the summary from the Import of a given CBL /// -public class CblImportSummaryDto +public sealed record CblImportSummaryDto { public string CblName { get; set; } /// diff --git a/API/DTOs/ReadingLists/CBL/CblReadingList.cs b/API/DTOs/ReadingLists/CBL/CblReadingList.cs index 001e6434b..15b349f42 100644 --- a/API/DTOs/ReadingLists/CBL/CblReadingList.cs +++ b/API/DTOs/ReadingLists/CBL/CblReadingList.cs @@ -5,7 +5,7 @@ namespace API.DTOs.ReadingLists.CBL; [XmlRoot(ElementName="Books")] -public class CblBooks +public sealed record CblBooks { [XmlElement(ElementName="Book")] public List Book { get; set; } @@ -13,7 +13,7 @@ public class CblBooks [XmlRoot(ElementName="ReadingList")] -public class CblReadingList +public sealed record CblReadingList { /// /// Name of the Reading List diff --git a/API/DTOs/ReadingLists/CreateReadingListDto.cs b/API/DTOs/ReadingLists/CreateReadingListDto.cs index 783253007..543215722 100644 --- a/API/DTOs/ReadingLists/CreateReadingListDto.cs +++ b/API/DTOs/ReadingLists/CreateReadingListDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.ReadingLists; -public class CreateReadingListDto +public sealed record CreateReadingListDto { public string Title { get; init; } = default!; } diff --git a/API/DTOs/ReadingLists/DeleteReadingListsDto.cs b/API/DTOs/ReadingLists/DeleteReadingListsDto.cs index 8417f8132..8ce92f939 100644 --- a/API/DTOs/ReadingLists/DeleteReadingListsDto.cs +++ b/API/DTOs/ReadingLists/DeleteReadingListsDto.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; namespace API.DTOs.ReadingLists; -public class DeleteReadingListsDto +public sealed record DeleteReadingListsDto { [Required] public IList ReadingListIds { get; set; } diff --git a/API/DTOs/ReadingLists/PromoteReadingListsDto.cs b/API/DTOs/ReadingLists/PromoteReadingListsDto.cs index f64bbb5ca..8915274de 100644 --- a/API/DTOs/ReadingLists/PromoteReadingListsDto.cs +++ b/API/DTOs/ReadingLists/PromoteReadingListsDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.ReadingLists; -public class PromoteReadingListsDto +public sealed record PromoteReadingListsDto { public IList ReadingListIds { get; init; } public bool Promoted { get; init; } diff --git a/API/DTOs/ReadingLists/ReadingListCast.cs b/API/DTOs/ReadingLists/ReadingListCast.cs index 4532df7d5..8f2587426 100644 --- a/API/DTOs/ReadingLists/ReadingListCast.cs +++ b/API/DTOs/ReadingLists/ReadingListCast.cs @@ -2,7 +2,7 @@ namespace API.DTOs.ReadingLists; -public class ReadingListCast +public sealed record ReadingListCast { public ICollection Writers { get; set; } = []; public ICollection CoverArtists { get; set; } = []; diff --git a/API/DTOs/ReadingLists/ReadingListDto.cs b/API/DTOs/ReadingLists/ReadingListDto.cs index 6508e7bd4..cbc16275d 100644 --- a/API/DTOs/ReadingLists/ReadingListDto.cs +++ b/API/DTOs/ReadingLists/ReadingListDto.cs @@ -5,7 +5,7 @@ using API.Entities.Interfaces; namespace API.DTOs.ReadingLists; #nullable enable -public class ReadingListDto : IHasCoverImage +public sealed record ReadingListDto : IHasCoverImage { public int Id { get; init; } public string Title { get; set; } = default!; @@ -20,8 +20,8 @@ public class ReadingListDto : IHasCoverImage /// public string? CoverImage { get; set; } = string.Empty; - public string PrimaryColor { get; set; } = string.Empty; - public string SecondaryColor { get; set; } = string.Empty; + public string? PrimaryColor { get; set; } = string.Empty; + public string? SecondaryColor { get; set; } = string.Empty; /// /// Number of Items in the Reading List diff --git a/API/DTOs/ReadingLists/ReadingListInfoDto.cs b/API/DTOs/ReadingLists/ReadingListInfoDto.cs index bd95b9226..64a305f43 100644 --- a/API/DTOs/ReadingLists/ReadingListInfoDto.cs +++ b/API/DTOs/ReadingLists/ReadingListInfoDto.cs @@ -3,7 +3,7 @@ using API.Entities.Interfaces; namespace API.DTOs.ReadingLists; -public class ReadingListInfoDto : IHasReadTimeEstimate +public sealed record ReadingListInfoDto : IHasReadTimeEstimate { /// /// Total Pages across all Reading List Items diff --git a/API/DTOs/ReadingLists/ReadingListItemDto.cs b/API/DTOs/ReadingLists/ReadingListItemDto.cs index 4fca5360c..8edec14f1 100644 --- a/API/DTOs/ReadingLists/ReadingListItemDto.cs +++ b/API/DTOs/ReadingLists/ReadingListItemDto.cs @@ -4,7 +4,7 @@ using API.Entities.Enums; namespace API.DTOs.ReadingLists; #nullable enable -public class ReadingListItemDto +public sealed record ReadingListItemDto { public int Id { get; init; } public int Order { get; init; } diff --git a/API/DTOs/ReadingLists/UpdateReadingListByChapterDto.cs b/API/DTOs/ReadingLists/UpdateReadingListByChapterDto.cs index 985f86ac0..6624c8a5c 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListByChapterDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListByChapterDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.ReadingLists; -public class UpdateReadingListByChapterDto +public sealed record UpdateReadingListByChapterDto { public int ChapterId { get; init; } public int SeriesId { get; init; } diff --git a/API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs b/API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs index 408963529..ba7625088 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.ReadingLists; -public class UpdateReadingListByMultipleDto +public sealed record UpdateReadingListByMultipleDto { public int SeriesId { get; init; } public int ReadingListId { get; init; } diff --git a/API/DTOs/ReadingLists/UpdateReadingListByMultipleSeriesDto.cs b/API/DTOs/ReadingLists/UpdateReadingListByMultipleSeriesDto.cs index f910e9c06..910a5744d 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListByMultipleSeriesDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListByMultipleSeriesDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.ReadingLists; -public class UpdateReadingListByMultipleSeriesDto +public sealed record UpdateReadingListByMultipleSeriesDto { public int ReadingListId { get; init; } public IReadOnlyList SeriesIds { get; init; } = default!; diff --git a/API/DTOs/ReadingLists/UpdateReadingListBySeriesDto.cs b/API/DTOs/ReadingLists/UpdateReadingListBySeriesDto.cs index 0590882bd..4bb4aa7bb 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListBySeriesDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListBySeriesDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.ReadingLists; -public class UpdateReadingListBySeriesDto +public sealed record UpdateReadingListBySeriesDto { public int SeriesId { get; init; } public int ReadingListId { get; init; } diff --git a/API/DTOs/ReadingLists/UpdateReadingListByVolumeDto.cs b/API/DTOs/ReadingLists/UpdateReadingListByVolumeDto.cs index f77c7d63a..422d1cc34 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListByVolumeDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListByVolumeDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.ReadingLists; -public class UpdateReadingListByVolumeDto +public sealed record UpdateReadingListByVolumeDto { public int VolumeId { get; init; } public int SeriesId { get; init; } diff --git a/API/DTOs/ReadingLists/UpdateReadingListDto.cs b/API/DTOs/ReadingLists/UpdateReadingListDto.cs index 6b590707a..de273d825 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.ReadingLists; -public class UpdateReadingListDto +public sealed record UpdateReadingListDto { [Required] public int ReadingListId { get; set; } diff --git a/API/DTOs/ReadingLists/UpdateReadingListPosition.cs b/API/DTOs/ReadingLists/UpdateReadingListPosition.cs index 3d0487144..04f2501a8 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListPosition.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListPosition.cs @@ -5,7 +5,7 @@ namespace API.DTOs.ReadingLists; /// /// DTO for moving a reading list item to another position within the same list /// -public class UpdateReadingListPosition +public sealed record UpdateReadingListPosition { [Required] public int ReadingListId { get; set; } [Required] public int ReadingListItemId { get; set; } diff --git a/API/DTOs/Recommendation/ExternalSeriesDto.cs b/API/DTOs/Recommendation/ExternalSeriesDto.cs index d393443af..752001a39 100644 --- a/API/DTOs/Recommendation/ExternalSeriesDto.cs +++ b/API/DTOs/Recommendation/ExternalSeriesDto.cs @@ -3,7 +3,7 @@ namespace API.DTOs.Recommendation; #nullable enable -public class ExternalSeriesDto +public sealed record ExternalSeriesDto { public required string Name { get; set; } public required string CoverUrl { get; set; } diff --git a/API/DTOs/Recommendation/MetadataTagDto.cs b/API/DTOs/Recommendation/MetadataTagDto.cs index b219dedc1..a7eb76284 100644 --- a/API/DTOs/Recommendation/MetadataTagDto.cs +++ b/API/DTOs/Recommendation/MetadataTagDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Recommendation; -public class MetadataTagDto +public sealed record MetadataTagDto { public string Name { get; set; } public string Description { get; private set; } diff --git a/API/DTOs/Recommendation/RecommendationDto.cs b/API/DTOs/Recommendation/RecommendationDto.cs index 679245a87..387661324 100644 --- a/API/DTOs/Recommendation/RecommendationDto.cs +++ b/API/DTOs/Recommendation/RecommendationDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Recommendation; -public class RecommendationDto +public sealed record RecommendationDto { public IList OwnedSeries { get; set; } = new List(); public IList ExternalSeries { get; set; } = new List(); diff --git a/API/DTOs/Recommendation/SeriesStaffDto.cs b/API/DTOs/Recommendation/SeriesStaffDto.cs index e4c6f6423..e074e8625 100644 --- a/API/DTOs/Recommendation/SeriesStaffDto.cs +++ b/API/DTOs/Recommendation/SeriesStaffDto.cs @@ -1,7 +1,7 @@ namespace API.DTOs.Recommendation; #nullable enable -public class SeriesStaffDto +public sealed record SeriesStaffDto { public required string Name { get; set; } public string? FirstName { get; set; } diff --git a/API/DTOs/RefreshSeriesDto.cs b/API/DTOs/RefreshSeriesDto.cs index 0e94fc44b..ad26afba2 100644 --- a/API/DTOs/RefreshSeriesDto.cs +++ b/API/DTOs/RefreshSeriesDto.cs @@ -3,7 +3,7 @@ /// /// Used for running some task against a Series. /// -public class RefreshSeriesDto +public sealed record RefreshSeriesDto { /// /// Library Id series belongs to diff --git a/API/DTOs/RegisterDto.cs b/API/DTOs/RegisterDto.cs index 2d4d3b77f..e117af872 100644 --- a/API/DTOs/RegisterDto.cs +++ b/API/DTOs/RegisterDto.cs @@ -3,7 +3,7 @@ namespace API.DTOs; #nullable enable -public class RegisterDto +public sealed record RegisterDto { [Required] public string Username { get; init; } = default!; diff --git a/API/DTOs/ScanFolderDto.cs b/API/DTOs/ScanFolderDto.cs index 684de909e..141f7f0b5 100644 --- a/API/DTOs/ScanFolderDto.cs +++ b/API/DTOs/ScanFolderDto.cs @@ -3,7 +3,7 @@ /// /// DTO for requesting a folder to be scanned /// -public class ScanFolderDto +public sealed record ScanFolderDto { /// /// Api key for a user with Admin permissions diff --git a/API/DTOs/Scrobbling/MalUserInfoDto.cs b/API/DTOs/Scrobbling/MalUserInfoDto.cs index 407639e2a..b6fefc053 100644 --- a/API/DTOs/Scrobbling/MalUserInfoDto.cs +++ b/API/DTOs/Scrobbling/MalUserInfoDto.cs @@ -3,7 +3,7 @@ /// /// Information about a User's MAL connection /// -public class MalUserInfoDto +public sealed record MalUserInfoDto { public required string Username { get; set; } /// diff --git a/API/DTOs/Scrobbling/MediaRecommendationDto.cs b/API/DTOs/Scrobbling/MediaRecommendationDto.cs index 3f565296b..476d77279 100644 --- a/API/DTOs/Scrobbling/MediaRecommendationDto.cs +++ b/API/DTOs/Scrobbling/MediaRecommendationDto.cs @@ -4,7 +4,7 @@ using API.Services.Plus; namespace API.DTOs.Scrobbling; #nullable enable -public record MediaRecommendationDto +public sealed record MediaRecommendationDto { public int Rating { get; set; } public IEnumerable RecommendationNames { get; set; } = null!; diff --git a/API/DTOs/Scrobbling/PlusSeriesDto.cs b/API/DTOs/Scrobbling/PlusSeriesDto.cs index dca9aca92..4d0ef4ea1 100644 --- a/API/DTOs/Scrobbling/PlusSeriesDto.cs +++ b/API/DTOs/Scrobbling/PlusSeriesDto.cs @@ -4,7 +4,7 @@ /// /// Represents information about a potential Series for Kavita+ /// -public record PlusSeriesRequestDto +public sealed record PlusSeriesRequestDto { public int? AniListId { get; set; } public long? MalId { get; set; } diff --git a/API/DTOs/Scrobbling/ScrobbleDto.cs b/API/DTOs/Scrobbling/ScrobbleDto.cs index e8420e785..b90441059 100644 --- a/API/DTOs/Scrobbling/ScrobbleDto.cs +++ b/API/DTOs/Scrobbling/ScrobbleDto.cs @@ -36,7 +36,7 @@ public enum PlusMediaFormat } -public class ScrobbleDto +public sealed record ScrobbleDto { /// /// User's access token to allow us to talk on their behalf diff --git a/API/DTOs/Scrobbling/ScrobbleErrorDto.cs b/API/DTOs/Scrobbling/ScrobbleErrorDto.cs index da85f28f1..7caaad1ca 100644 --- a/API/DTOs/Scrobbling/ScrobbleErrorDto.cs +++ b/API/DTOs/Scrobbling/ScrobbleErrorDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Scrobbling; -public class ScrobbleErrorDto +public sealed record ScrobbleErrorDto { /// /// Developer defined string diff --git a/API/DTOs/Scrobbling/ScrobbleEventDto.cs b/API/DTOs/Scrobbling/ScrobbleEventDto.cs index b62c87866..7b1ccd75a 100644 --- a/API/DTOs/Scrobbling/ScrobbleEventDto.cs +++ b/API/DTOs/Scrobbling/ScrobbleEventDto.cs @@ -3,7 +3,7 @@ namespace API.DTOs.Scrobbling; #nullable enable -public class ScrobbleEventDto +public sealed record ScrobbleEventDto { public string SeriesName { get; set; } public int SeriesId { get; set; } diff --git a/API/DTOs/Scrobbling/ScrobbleHoldDto.cs b/API/DTOs/Scrobbling/ScrobbleHoldDto.cs index dcfe7726f..3e09e4799 100644 --- a/API/DTOs/Scrobbling/ScrobbleHoldDto.cs +++ b/API/DTOs/Scrobbling/ScrobbleHoldDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Scrobbling; -public class ScrobbleHoldDto +public sealed record ScrobbleHoldDto { public string SeriesName { get; set; } public int SeriesId { get; set; } diff --git a/API/DTOs/Scrobbling/ScrobbleResponseDto.cs b/API/DTOs/Scrobbling/ScrobbleResponseDto.cs index a63e955d7..53d3a0cc9 100644 --- a/API/DTOs/Scrobbling/ScrobbleResponseDto.cs +++ b/API/DTOs/Scrobbling/ScrobbleResponseDto.cs @@ -4,7 +4,7 @@ /// /// Response from Kavita+ Scrobble API /// -public class ScrobbleResponseDto +public sealed record ScrobbleResponseDto { public bool Successful { get; set; } public string? ErrorMessage { get; set; } diff --git a/API/DTOs/Search/BookmarkSearchResultDto.cs b/API/DTOs/Search/BookmarkSearchResultDto.cs index 5d53add1f..c11d2a2b8 100644 --- a/API/DTOs/Search/BookmarkSearchResultDto.cs +++ b/API/DTOs/Search/BookmarkSearchResultDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Search; -public class BookmarkSearchResultDto +public sealed record BookmarkSearchResultDto { public int LibraryId { get; set; } public int VolumeId { get; set; } diff --git a/API/DTOs/Search/SearchResultDto.cs b/API/DTOs/Search/SearchResultDto.cs index 6fcae3b5d..c497b55dd 100644 --- a/API/DTOs/Search/SearchResultDto.cs +++ b/API/DTOs/Search/SearchResultDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Search; -public class SearchResultDto +public sealed record SearchResultDto { public int SeriesId { get; init; } public string Name { get; init; } = default!; diff --git a/API/DTOs/Search/SearchResultGroupDto.cs b/API/DTOs/Search/SearchResultGroupDto.cs index f7a622664..20a53f853 100644 --- a/API/DTOs/Search/SearchResultGroupDto.cs +++ b/API/DTOs/Search/SearchResultGroupDto.cs @@ -10,7 +10,7 @@ namespace API.DTOs.Search; /// /// Represents all Search results for a query /// -public class SearchResultGroupDto +public sealed record SearchResultGroupDto { public IEnumerable Libraries { get; set; } = default!; public IEnumerable Series { get; set; } = default!; diff --git a/API/DTOs/SeriesByIdsDto.cs b/API/DTOs/SeriesByIdsDto.cs index 12e13d96f..cb4c52b1e 100644 --- a/API/DTOs/SeriesByIdsDto.cs +++ b/API/DTOs/SeriesByIdsDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs; -public class SeriesByIdsDto +public sealed record SeriesByIdsDto { public int[] SeriesIds { get; init; } = default!; } diff --git a/API/DTOs/SeriesDetail/NextExpectedChapterDto.cs b/API/DTOs/SeriesDetail/NextExpectedChapterDto.cs index 0f1a8eb4b..1bea81c84 100644 --- a/API/DTOs/SeriesDetail/NextExpectedChapterDto.cs +++ b/API/DTOs/SeriesDetail/NextExpectedChapterDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.SeriesDetail; -public class NextExpectedChapterDto +public sealed record NextExpectedChapterDto { public float ChapterNumber { get; set; } public float VolumeNumber { get; set; } diff --git a/API/DTOs/SeriesDetail/RelatedSeriesDto.cs b/API/DTOs/SeriesDetail/RelatedSeriesDto.cs index 29b9eb263..a186dc295 100644 --- a/API/DTOs/SeriesDetail/RelatedSeriesDto.cs +++ b/API/DTOs/SeriesDetail/RelatedSeriesDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.SeriesDetail; -public class RelatedSeriesDto +public sealed record RelatedSeriesDto { /// /// The parent relationship Series diff --git a/API/DTOs/SeriesDetail/SeriesDetailDto.cs b/API/DTOs/SeriesDetail/SeriesDetailDto.cs index 65d657c67..c4f15552d 100644 --- a/API/DTOs/SeriesDetail/SeriesDetailDto.cs +++ b/API/DTOs/SeriesDetail/SeriesDetailDto.cs @@ -7,7 +7,7 @@ namespace API.DTOs.SeriesDetail; /// This is a special DTO for a UI page in Kavita. This performs sorting and grouping and returns exactly what UI requires for layout. /// This is subject to change, do not rely on this Data model. /// -public class SeriesDetailDto +public sealed record SeriesDetailDto { /// /// Specials for the Series. These will have their title and range cleaned to remove the special marker and prepare diff --git a/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs b/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs index 76e77ae2c..95f5f39bd 100644 --- a/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs +++ b/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Recommendation; namespace API.DTOs.SeriesDetail; @@ -8,7 +9,7 @@ namespace API.DTOs.SeriesDetail; /// All the data from Kavita+ for Series Detail /// /// This is what the UI sees, not what the API sends back -public class SeriesDetailPlusDto +public sealed record SeriesDetailPlusDto { public RecommendationDto? Recommendations { get; set; } public IEnumerable Reviews { get; set; } diff --git a/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs b/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs index f19ad9ca8..a1bb2057e 100644 --- a/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs +++ b/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.SeriesDetail; -public class UpdateRelatedSeriesDto +public sealed record UpdateRelatedSeriesDto { public int SeriesId { get; set; } public IList Adaptations { get; set; } = default!; diff --git a/API/DTOs/SeriesDetail/UpdateUserReviewDto.cs b/API/DTOs/SeriesDetail/UpdateUserReviewDto.cs index adff04d6c..7af9441c1 100644 --- a/API/DTOs/SeriesDetail/UpdateUserReviewDto.cs +++ b/API/DTOs/SeriesDetail/UpdateUserReviewDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.SeriesDetail; #nullable enable -public class UpdateUserReviewDto +public sealed record UpdateUserReviewDto { public int SeriesId { get; set; } public int? ChapterId { get; set; } diff --git a/API/DTOs/SeriesDetail/UserReviewDto.cs b/API/DTOs/SeriesDetail/UserReviewDto.cs index c8340a40a..9e05bbd65 100644 --- a/API/DTOs/SeriesDetail/UserReviewDto.cs +++ b/API/DTOs/SeriesDetail/UserReviewDto.cs @@ -9,7 +9,7 @@ namespace API.DTOs.SeriesDetail; /// Represents a User Review for a given Series /// /// The user does not need to be a Kavita user -public class UserReviewDto +public sealed record UserReviewDto { /// /// A tagline for the review diff --git a/API/DTOs/SeriesDto.cs b/API/DTOs/SeriesDto.cs index 6aa1ecefd..8a49d4c05 100644 --- a/API/DTOs/SeriesDto.cs +++ b/API/DTOs/SeriesDto.cs @@ -5,14 +5,21 @@ using API.Entities.Interfaces; namespace API.DTOs; #nullable enable -public class SeriesDto : IHasReadTimeEstimate, IHasCoverImage +public sealed record SeriesDto : IHasReadTimeEstimate, IHasCoverImage { + /// public int Id { get; init; } + /// public string? Name { get; init; } + /// public string? OriginalName { get; init; } + /// public string? LocalizedName { get; init; } + /// public string? SortName { get; init; } + /// public int Pages { get; init; } + /// public bool CoverImageLocked { get; set; } /// /// Sum of pages read from linked Volumes. Calculated at API-time. @@ -22,9 +29,7 @@ public class SeriesDto : IHasReadTimeEstimate, IHasCoverImage /// DateTime representing last time the series was Read. Calculated at API-time. /// public DateTime LatestReadDate { get; set; } - /// - /// DateTime representing last time a chapter was added to the Series - /// + /// public DateTime LastChapterAdded { get; set; } /// /// Rating from logged in user. Calculated at API-time. @@ -35,17 +40,19 @@ public class SeriesDto : IHasReadTimeEstimate, IHasCoverImage /// public bool HasUserRated { get; set; } + /// public MangaFormat Format { get; set; } + /// public DateTime Created { get; set; } - public bool NameLocked { get; set; } + /// public bool SortNameLocked { get; set; } + /// public bool LocalizedNameLocked { get; set; } - /// - /// Total number of words for the series. Only applies to epubs. - /// + /// public long WordCount { get; set; } + /// public int LibraryId { get; set; } public string LibraryName { get; set; } = default!; /// @@ -54,33 +61,25 @@ public class SeriesDto : IHasReadTimeEstimate, IHasCoverImage public int MaxHoursToRead { get; set; } /// public float AvgHoursToRead { get; set; } - /// - /// The highest level folder for this Series - /// + /// public string FolderPath { get; set; } = default!; - /// - /// Lowest path (that is under library root) that contains all files for the series. - /// - /// must be used before setting + /// public string? LowestFolderPath { get; set; } - /// - /// The last time the folder for this series was scanned - /// + /// public DateTime LastFolderScanned { get; set; } #region KavitaPlus - /// - /// Do not match the series with any external Metadata service. This will automatically opt it out of scrobbling. - /// + /// public bool DontMatch { get; set; } - /// - /// If the series was unable to match, it will be blacklisted until a manual metadata match overrides it - /// + /// public bool IsBlacklisted { get; set; } #endregion + /// public string? CoverImage { get; set; } - public string PrimaryColor { get; set; } = string.Empty; - public string SecondaryColor { get; set; } = string.Empty; + /// + public string? PrimaryColor { get; set; } = string.Empty; + /// + public string? SecondaryColor { get; set; } = string.Empty; public void ResetColorScape() { diff --git a/API/DTOs/SeriesMetadataDto.cs b/API/DTOs/SeriesMetadataDto.cs index 3f344dff5..701034d80 100644 --- a/API/DTOs/SeriesMetadataDto.cs +++ b/API/DTOs/SeriesMetadataDto.cs @@ -4,7 +4,7 @@ using API.Entities.Enums; namespace API.DTOs; -public class SeriesMetadataDto +public sealed record SeriesMetadataDto { public int Id { get; set; } public string Summary { get; set; } = string.Empty; diff --git a/API/DTOs/Settings/SMTPConfigDto.cs b/API/DTOs/Settings/SMTPConfigDto.cs index 07cc58cb8..c14140062 100644 --- a/API/DTOs/Settings/SMTPConfigDto.cs +++ b/API/DTOs/Settings/SMTPConfigDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Settings; -public class SmtpConfigDto +public sealed record SmtpConfigDto { public string SenderAddress { get; set; } = string.Empty; public string SenderDisplayName { get; set; } = string.Empty; diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index 78db88d7d..372042250 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -6,7 +6,7 @@ using API.Services; namespace API.DTOs.Settings; #nullable enable -public class ServerSettingDto +public sealed record ServerSettingDto { public string CacheDirectory { get; set; } = default!; diff --git a/API/DTOs/SideNav/BulkUpdateSideNavStreamVisibilityDto.cs b/API/DTOs/SideNav/BulkUpdateSideNavStreamVisibilityDto.cs index 1b081913d..ae1d927a9 100644 --- a/API/DTOs/SideNav/BulkUpdateSideNavStreamVisibilityDto.cs +++ b/API/DTOs/SideNav/BulkUpdateSideNavStreamVisibilityDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.SideNav; -public class BulkUpdateSideNavStreamVisibilityDto +public sealed record BulkUpdateSideNavStreamVisibilityDto { public required IList Ids { get; set; } public required bool Visibility { get; set; } diff --git a/API/DTOs/SideNav/ExternalSourceDto.cs b/API/DTOs/SideNav/ExternalSourceDto.cs index e9ae03066..382124e8a 100644 --- a/API/DTOs/SideNav/ExternalSourceDto.cs +++ b/API/DTOs/SideNav/ExternalSourceDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.SideNav; -public class ExternalSourceDto +public sealed record ExternalSourceDto { public required int Id { get; set; } = 0; public required string Name { get; set; } diff --git a/API/DTOs/SideNav/SideNavStreamDto.cs b/API/DTOs/SideNav/SideNavStreamDto.cs index fdef82a08..f4c196244 100644 --- a/API/DTOs/SideNav/SideNavStreamDto.cs +++ b/API/DTOs/SideNav/SideNavStreamDto.cs @@ -4,7 +4,7 @@ using API.Entities.Enums; namespace API.DTOs.SideNav; #nullable enable -public class SideNavStreamDto +public sealed record SideNavStreamDto { public int Id { get; set; } public required string Name { get; set; } diff --git a/API/DTOs/Statistics/Count.cs b/API/DTOs/Statistics/Count.cs index 411b44897..1577e682c 100644 --- a/API/DTOs/Statistics/Count.cs +++ b/API/DTOs/Statistics/Count.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Statistics; -public class StatCount : ICount +public sealed record StatCount : ICount { public T Value { get; set; } = default!; public long Count { get; set; } diff --git a/API/DTOs/Statistics/FileExtensionBreakdownDto.cs b/API/DTOs/Statistics/FileExtensionBreakdownDto.cs index 1f122d992..7a248caef 100644 --- a/API/DTOs/Statistics/FileExtensionBreakdownDto.cs +++ b/API/DTOs/Statistics/FileExtensionBreakdownDto.cs @@ -4,7 +4,7 @@ using API.Entities.Enums; namespace API.DTOs.Statistics; #nullable enable -public class FileExtensionDto +public sealed record FileExtensionDto { public string? Extension { get; set; } public MangaFormat Format { get; set; } @@ -12,7 +12,7 @@ public class FileExtensionDto public long TotalFiles { get; set; } } -public class FileExtensionBreakdownDto +public sealed record FileExtensionBreakdownDto { /// /// Total bytes for all files diff --git a/API/DTOs/Statistics/PagesReadOnADayCount.cs b/API/DTOs/Statistics/PagesReadOnADayCount.cs index b1a6bb1ea..fc56d9cc0 100644 --- a/API/DTOs/Statistics/PagesReadOnADayCount.cs +++ b/API/DTOs/Statistics/PagesReadOnADayCount.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Statistics; -public class PagesReadOnADayCount : ICount +public sealed record PagesReadOnADayCount : ICount { /// /// The day of the readings diff --git a/API/DTOs/Statistics/ReadHistoryEvent.cs b/API/DTOs/Statistics/ReadHistoryEvent.cs index 496148789..5d8262aef 100644 --- a/API/DTOs/Statistics/ReadHistoryEvent.cs +++ b/API/DTOs/Statistics/ReadHistoryEvent.cs @@ -6,7 +6,7 @@ namespace API.DTOs.Statistics; /// /// Represents a single User's reading event /// -public class ReadHistoryEvent +public sealed record ReadHistoryEvent { public int UserId { get; set; } public required string? UserName { get; set; } = default!; diff --git a/API/DTOs/Statistics/ServerStatisticsDto.cs b/API/DTOs/Statistics/ServerStatisticsDto.cs index 57fd5abce..3d22d9a56 100644 --- a/API/DTOs/Statistics/ServerStatisticsDto.cs +++ b/API/DTOs/Statistics/ServerStatisticsDto.cs @@ -3,7 +3,7 @@ namespace API.DTOs.Statistics; #nullable enable -public class ServerStatisticsDto +public sealed record ServerStatisticsDto { public long ChapterCount { get; set; } public long VolumeCount { get; set; } diff --git a/API/DTOs/Statistics/TopReadsDto.cs b/API/DTOs/Statistics/TopReadsDto.cs index 806360533..d11594dca 100644 --- a/API/DTOs/Statistics/TopReadsDto.cs +++ b/API/DTOs/Statistics/TopReadsDto.cs @@ -1,7 +1,7 @@ namespace API.DTOs.Statistics; #nullable enable -public class TopReadDto +public sealed record TopReadDto { public int UserId { get; set; } public string? Username { get; set; } = default!; diff --git a/API/DTOs/Statistics/UserReadStatistics.cs b/API/DTOs/Statistics/UserReadStatistics.cs index 5da4b491e..5c6935c6e 100644 --- a/API/DTOs/Statistics/UserReadStatistics.cs +++ b/API/DTOs/Statistics/UserReadStatistics.cs @@ -4,7 +4,7 @@ using System.Collections.Generic; namespace API.DTOs.Statistics; #nullable enable -public class UserReadStatistics +public sealed record UserReadStatistics { /// /// Total number of pages read diff --git a/API/DTOs/Stats/FileExtensionExportDto.cs b/API/DTOs/Stats/FileExtensionExportDto.cs index 6ed554d75..e881960a5 100644 --- a/API/DTOs/Stats/FileExtensionExportDto.cs +++ b/API/DTOs/Stats/FileExtensionExportDto.cs @@ -5,7 +5,7 @@ namespace API.DTOs.Stats; /// /// Excel export for File Extension Report /// -public class FileExtensionExportDto +public sealed record FileExtensionExportDto { [Name("Path")] public string FilePath { get; set; } diff --git a/API/DTOs/Stats/ServerInfoSlimDto.cs b/API/DTOs/Stats/ServerInfoSlimDto.cs index 0b47fa2f3..f1abb2e1d 100644 --- a/API/DTOs/Stats/ServerInfoSlimDto.cs +++ b/API/DTOs/Stats/ServerInfoSlimDto.cs @@ -6,7 +6,7 @@ namespace API.DTOs.Stats; /// /// This is just for the Server tab on UI /// -public class ServerInfoSlimDto +public sealed record ServerInfoSlimDto { /// /// Unique Id that represents a unique install diff --git a/API/DTOs/Stats/V3/LibraryStatV3.cs b/API/DTOs/Stats/V3/LibraryStatV3.cs index 51af34b58..33ac86d2b 100644 --- a/API/DTOs/Stats/V3/LibraryStatV3.cs +++ b/API/DTOs/Stats/V3/LibraryStatV3.cs @@ -4,7 +4,7 @@ using API.Entities.Enums; namespace API.DTOs.Stats.V3; -public class LibraryStatV3 +public sealed record LibraryStatV3 { public bool IncludeInDashboard { get; set; } public bool IncludeInSearch { get; set; } diff --git a/API/DTOs/Stats/V3/RelationshipStatV3.cs b/API/DTOs/Stats/V3/RelationshipStatV3.cs index e8e1e7440..37b63cb9a 100644 --- a/API/DTOs/Stats/V3/RelationshipStatV3.cs +++ b/API/DTOs/Stats/V3/RelationshipStatV3.cs @@ -5,7 +5,7 @@ namespace API.DTOs.Stats.V3; /// /// KavitaStats - Information about Series Relationships /// -public class RelationshipStatV3 +public sealed record RelationshipStatV3 { public int Count { get; set; } public RelationKind Relationship { get; set; } diff --git a/API/DTOs/Stats/V3/ServerInfoV3Dto.cs b/API/DTOs/Stats/V3/ServerInfoV3Dto.cs index 0bf95403f..8ed3079f5 100644 --- a/API/DTOs/Stats/V3/ServerInfoV3Dto.cs +++ b/API/DTOs/Stats/V3/ServerInfoV3Dto.cs @@ -7,7 +7,7 @@ namespace API.DTOs.Stats.V3; /// /// Represents information about a Kavita Installation for Kavita Stats v3 API /// -public class ServerInfoV3Dto +public sealed record ServerInfoV3Dto { /// /// Unique Id that represents a unique install diff --git a/API/DTOs/Stats/V3/UserStatV3.cs b/API/DTOs/Stats/V3/UserStatV3.cs index 7f4e080ba..450a2e409 100644 --- a/API/DTOs/Stats/V3/UserStatV3.cs +++ b/API/DTOs/Stats/V3/UserStatV3.cs @@ -5,7 +5,7 @@ using API.Entities.Enums.Device; namespace API.DTOs.Stats.V3; -public class UserStatV3 +public sealed record UserStatV3 { public AgeRestriction AgeRestriction { get; set; } /// diff --git a/API/DTOs/System/DirectoryDto.cs b/API/DTOs/System/DirectoryDto.cs index e6e94f4e4..3b1408f7f 100644 --- a/API/DTOs/System/DirectoryDto.cs +++ b/API/DTOs/System/DirectoryDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.System; -public class DirectoryDto +public sealed record DirectoryDto { /// /// Name of the directory diff --git a/API/DTOs/Theme/ColorScapeDto.cs b/API/DTOs/Theme/ColorScapeDto.cs index 066e87d84..2ebd96e2b 100644 --- a/API/DTOs/Theme/ColorScapeDto.cs +++ b/API/DTOs/Theme/ColorScapeDto.cs @@ -4,7 +4,7 @@ /// /// A set of colors for the color scape system in the UI /// -public class ColorScapeDto +public sealed record ColorScapeDto { public string? Primary { get; set; } public string? Secondary { get; set; } diff --git a/API/DTOs/Theme/DownloadableSiteThemeDto.cs b/API/DTOs/Theme/DownloadableSiteThemeDto.cs index dbcedfe61..b27263d92 100644 --- a/API/DTOs/Theme/DownloadableSiteThemeDto.cs +++ b/API/DTOs/Theme/DownloadableSiteThemeDto.cs @@ -4,7 +4,7 @@ using System.Collections.Generic; namespace API.DTOs.Theme; -public class DownloadableSiteThemeDto +public sealed record DownloadableSiteThemeDto { /// /// Theme Name diff --git a/API/DTOs/Theme/SiteThemeDto.cs b/API/DTOs/Theme/SiteThemeDto.cs index eb2a14904..7ae8369e9 100644 --- a/API/DTOs/Theme/SiteThemeDto.cs +++ b/API/DTOs/Theme/SiteThemeDto.cs @@ -7,7 +7,7 @@ namespace API.DTOs.Theme; /// /// Represents a set of css overrides the user can upload to Kavita and will load into webui /// -public class SiteThemeDto +public sealed record SiteThemeDto { public int Id { get; set; } /// diff --git a/API/DTOs/Theme/UpdateDefaultThemeDto.cs b/API/DTOs/Theme/UpdateDefaultThemeDto.cs index 0f2b129f3..aac0858c3 100644 --- a/API/DTOs/Theme/UpdateDefaultThemeDto.cs +++ b/API/DTOs/Theme/UpdateDefaultThemeDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Theme; -public class UpdateDefaultThemeDto +public sealed record UpdateDefaultThemeDto { public int ThemeId { get; set; } } diff --git a/API/DTOs/Update/UpdateNotificationDto.cs b/API/DTOs/Update/UpdateNotificationDto.cs index 2f9550746..b535684f0 100644 --- a/API/DTOs/Update/UpdateNotificationDto.cs +++ b/API/DTOs/Update/UpdateNotificationDto.cs @@ -6,7 +6,7 @@ namespace API.DTOs.Update; /// /// Update Notification denoting a new release available for user to update to /// -public class UpdateNotificationDto +public sealed record UpdateNotificationDto { /// /// Current installed Version diff --git a/API/DTOs/UpdateChapterDto.cs b/API/DTOs/UpdateChapterDto.cs index 2ca0a12a9..ec2f1cf62 100644 --- a/API/DTOs/UpdateChapterDto.cs +++ b/API/DTOs/UpdateChapterDto.cs @@ -5,7 +5,7 @@ using API.Entities.Enums; namespace API.DTOs; -public class UpdateChapterDto +public sealed record UpdateChapterDto { public int Id { get; init; } public string Summary { get; set; } = string.Empty; diff --git a/API/DTOs/UpdateLibraryDto.cs b/API/DTOs/UpdateLibraryDto.cs index de02f304d..9bd47fd39 100644 --- a/API/DTOs/UpdateLibraryDto.cs +++ b/API/DTOs/UpdateLibraryDto.cs @@ -4,7 +4,7 @@ using API.Entities.Enums; namespace API.DTOs; -public class UpdateLibraryDto +public sealed record UpdateLibraryDto { [Required] public int Id { get; init; } diff --git a/API/DTOs/UpdateLibraryForUserDto.cs b/API/DTOs/UpdateLibraryForUserDto.cs index c90b697e2..4ce8d0df8 100644 --- a/API/DTOs/UpdateLibraryForUserDto.cs +++ b/API/DTOs/UpdateLibraryForUserDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs; -public class UpdateLibraryForUserDto +public sealed record UpdateLibraryForUserDto { public required string Username { get; init; } public required IEnumerable SelectedLibraries { get; init; } = new List(); diff --git a/API/DTOs/UpdateRBSDto.cs b/API/DTOs/UpdateRBSDto.cs index a7e0c3fc9..fa8bb78f9 100644 --- a/API/DTOs/UpdateRBSDto.cs +++ b/API/DTOs/UpdateRBSDto.cs @@ -3,7 +3,7 @@ namespace API.DTOs; #nullable enable -public class UpdateRbsDto +public sealed record UpdateRbsDto { public required string Username { get; init; } public IList? Roles { get; init; } diff --git a/API/DTOs/UpdateRatingDto.cs b/API/DTOs/UpdateRatingDto.cs index f462fdc2b..472a94fe9 100644 --- a/API/DTOs/UpdateRatingDto.cs +++ b/API/DTOs/UpdateRatingDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs; -public class UpdateRatingDto +public sealed record UpdateRatingDto { public int SeriesId { get; init; } public int? ChapterId { get; init; } diff --git a/API/DTOs/UpdateSeriesDto.cs b/API/DTOs/UpdateSeriesDto.cs index ab4ffcb22..a4a9baf8c 100644 --- a/API/DTOs/UpdateSeriesDto.cs +++ b/API/DTOs/UpdateSeriesDto.cs @@ -1,7 +1,7 @@ namespace API.DTOs; #nullable enable -public class UpdateSeriesDto +public sealed record UpdateSeriesDto { public int Id { get; init; } public string? LocalizedName { get; init; } diff --git a/API/DTOs/UpdateSeriesMetadataDto.cs b/API/DTOs/UpdateSeriesMetadataDto.cs index 75150b3fa..5225f5486 100644 --- a/API/DTOs/UpdateSeriesMetadataDto.cs +++ b/API/DTOs/UpdateSeriesMetadataDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs; -public class UpdateSeriesMetadataDto +public sealed record UpdateSeriesMetadataDto { public SeriesMetadataDto SeriesMetadata { get; set; } = null!; } diff --git a/API/DTOs/Uploads/UploadFileDto.cs b/API/DTOs/Uploads/UploadFileDto.cs index 72fe7da9b..8d5cdf4cb 100644 --- a/API/DTOs/Uploads/UploadFileDto.cs +++ b/API/DTOs/Uploads/UploadFileDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Uploads; -public class UploadFileDto +public sealed record UploadFileDto { /// /// Id of the Entity diff --git a/API/DTOs/Uploads/UploadUrlDto.cs b/API/DTOs/Uploads/UploadUrlDto.cs index f2699befd..3f4e625c3 100644 --- a/API/DTOs/Uploads/UploadUrlDto.cs +++ b/API/DTOs/Uploads/UploadUrlDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Uploads; -public class UploadUrlDto +public sealed record UploadUrlDto { /// /// External url diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs index e89e17df9..88dc97a5d 100644 --- a/API/DTOs/UserDto.cs +++ b/API/DTOs/UserDto.cs @@ -5,7 +5,7 @@ using API.DTOs.Account; namespace API.DTOs; #nullable enable -public class UserDto +public sealed record UserDto { public string Username { get; init; } = null!; public string Email { get; init; } = null!; diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 14987ae77..6645a8f39 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -7,104 +7,61 @@ using API.Entities.Enums.UserPreferences; namespace API.DTOs; #nullable enable -public class UserPreferencesDto +public sealed record UserPreferencesDto { - /// - /// Manga Reader Option: What direction should the next/prev page buttons go - /// + /// [Required] public ReadingDirection ReadingDirection { get; set; } - /// - /// Manga Reader Option: How should the image be scaled to screen - /// + /// [Required] public ScalingOption ScalingOption { get; set; } - /// - /// Manga Reader Option: Which side of a split image should we show first - /// + /// [Required] public PageSplitOption PageSplitOption { get; set; } - /// - /// Manga Reader Option: How the manga reader should perform paging or reading of the file - /// - /// Webtoon uses scrolling to page, LeftRight uses paging by clicking left/right side of reader, UpDown uses paging - /// by clicking top/bottom sides of reader. - /// - /// + /// [Required] public ReaderMode ReaderMode { get; set; } - /// - /// Manga Reader Option: How many pages to display in the reader at once - /// + /// [Required] public LayoutMode LayoutMode { get; set; } - /// - /// Manga Reader Option: Emulate a book by applying a shadow effect on the pages - /// + /// [Required] public bool EmulateBook { get; set; } - /// - /// Manga Reader Option: Background color of the reader - /// + /// [Required] public string BackgroundColor { get; set; } = "#000000"; - /// - /// Manga Reader Option: Should swiping trigger pagination - /// + /// [Required] public bool SwipeToPaginate { get; set; } - /// - /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction - /// + /// [Required] public bool AutoCloseMenu { get; set; } - /// - /// Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change - /// + /// [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 - /// + /// [Required] public int BookReaderMargin { get; set; } - /// - /// Book Reader Option: Override line-height - /// + /// [Required] public int BookReaderLineSpacing { get; set; } - /// - /// Book Reader Option: Override font size - /// + /// [Required] public int BookReaderFontSize { get; set; } - /// - /// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override - /// + /// [Required] public string BookReaderFontFamily { get; set; } = null!; - - /// - /// Book Reader Option: Allows tapping on side of screens to paginate - /// + /// [Required] public bool BookReaderTapToPaginate { get; set; } - /// - /// Book Reader Option: What direction should the next/prev page buttons go - /// + /// [Required] public ReadingDirection BookReaderReadingDirection { get; set; } - - /// - /// Book Reader Option: What writing style should be used, horizontal or vertical. - /// + /// [Required] public WritingStyle BookReaderWritingStyle { get; set; } @@ -116,79 +73,46 @@ public class UserPreferencesDto public SiteThemeDto? Theme { get; set; } [Required] public string BookReaderThemeName { get; set; } = null!; + /// [Required] public BookPageLayoutMode BookReaderLayoutMode { get; set; } - /// - /// Book Reader Option: A flag that hides the menu-ing system behind a click on the screen. This should be used with tap to paginate, but the app doesn't enforce this. - /// - /// Defaults to false + /// [Required] public bool BookReaderImmersiveMode { get; set; } = false; - /// - /// Global Site Option: If the UI should layout items as Cards or List items - /// - /// Defaults to Cards + /// [Required] public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards; - /// - /// UI Site Global Setting: If unread summaries should be blurred until expanded or unless user has read it already - /// - /// Defaults to false + /// [Required] public bool BlurUnreadSummaries { get; set; } = false; - /// - /// UI Site Global Setting: Should Kavita prompt user to confirm downloads that are greater than 100 MB. - /// + /// [Required] public bool PromptForDownloadSize { get; set; } = true; - /// - /// UI Site Global Setting: Should Kavita disable CSS transitions - /// + /// [Required] public bool NoTransitions { get; set; } = false; - /// - /// When showing series, only parent series or series with no relationships will be returned - /// + /// [Required] public bool CollapseSeriesRelationships { get; set; } = false; - /// - /// UI Site Global Setting: Should series reviews be shared with all users in the server - /// + /// [Required] public bool ShareReviews { get; set; } = false; - /// - /// UI Site Global Setting: The language locale that should be used for the user - /// + /// [Required] public string Locale { get; set; } - /// - /// PDF Reader: Theme of the Reader - /// + /// [Required] public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark; - /// - /// PDF Reader: Scroll mode of the reader - /// + /// [Required] public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical; - /// - /// PDF Reader: Layout Mode of the reader - /// - [Required] - public PdfLayoutMode PdfLayoutMode { get; set; } = PdfLayoutMode.Multiple; - /// - /// PDF Reader: Spread Mode of the reader - /// + /// [Required] public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None; - /// - /// Kavita+: Should this account have Scrobbling enabled for AniList - /// + /// public bool AniListScrobblingEnabled { get; set; } - /// - /// Kavita+: Should this account have Want to Read Sync enabled - /// + /// public bool WantToReadSync { get; set; } } diff --git a/API/DTOs/VolumeDto.cs b/API/DTOs/VolumeDto.cs index 8ef22a93b..fffccea59 100644 --- a/API/DTOs/VolumeDto.cs +++ b/API/DTOs/VolumeDto.cs @@ -1,5 +1,4 @@ - -using System; +using System; using System.Collections.Generic; using API.Entities; using API.Entities.Interfaces; @@ -8,14 +7,15 @@ using API.Services.Tasks.Scanner.Parser; namespace API.DTOs; -public class VolumeDto : IHasReadTimeEstimate, IHasCoverImage +public sealed record VolumeDto : IHasReadTimeEstimate, IHasCoverImage { + /// public int Id { get; set; } - /// + /// public float MinNumber { get; set; } - /// + /// public float MaxNumber { get; set; } - /// + /// public string Name { get; set; } = default!; /// /// This will map to MinNumber. Number was removed in v0.7.13.8/v0.7.14 @@ -24,17 +24,21 @@ public class VolumeDto : IHasReadTimeEstimate, IHasCoverImage public int Number { get; set; } public int Pages { get; set; } public int PagesRead { get; set; } + /// public DateTime LastModifiedUtc { get; set; } + /// public DateTime CreatedUtc { get; set; } /// /// When chapter was created in local server time /// /// This is required for Tachiyomi Extension + /// public DateTime Created { get; set; } /// /// When chapter was last modified in local server time /// /// This is required for Tachiyomi Extension + /// public DateTime LastModified { get; set; } public int SeriesId { get; set; } public ICollection Chapters { get; set; } = new List(); @@ -64,10 +68,14 @@ public class VolumeDto : IHasReadTimeEstimate, IHasCoverImage return MinNumber.Is(Parser.SpecialVolumeNumber); } + /// public string CoverImage { get; set; } + /// private bool CoverImageLocked { get; set; } - public string PrimaryColor { get; set; } = string.Empty; - public string SecondaryColor { get; set; } = string.Empty; + /// + public string? PrimaryColor { get; set; } = string.Empty; + /// + public string? SecondaryColor { get; set; } = string.Empty; public void ResetColorScape() { diff --git a/API/DTOs/WantToRead/UpdateWantToReadDto.cs b/API/DTOs/WantToRead/UpdateWantToReadDto.cs index f1b38cea2..a5be26857 100644 --- a/API/DTOs/WantToRead/UpdateWantToReadDto.cs +++ b/API/DTOs/WantToRead/UpdateWantToReadDto.cs @@ -6,7 +6,7 @@ namespace API.DTOs.WantToRead; /// /// A list of Series to pass when working with Want To Read APIs /// -public class UpdateWantToReadDto +public sealed record UpdateWantToReadDto { /// /// List of Series Ids that will be Added/Removed diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 948f35a68..d9c78c770 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -13,6 +13,7 @@ using API.DTOs.CollectionTags; using API.DTOs.Dashboard; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Metadata; using API.DTOs.ReadingLists; using API.DTOs.Recommendation; diff --git a/API/Entities/Metadata/ExternalRating.cs b/API/Entities/Metadata/ExternalRating.cs index 9922c7f80..7fc2b9353 100644 --- a/API/Entities/Metadata/ExternalRating.cs +++ b/API/Entities/Metadata/ExternalRating.cs @@ -11,6 +11,9 @@ public class ExternalRating public int AverageScore { get; set; } public int FavoriteCount { get; set; } public ScrobbleProvider Provider { get; set; } + /// + /// Where this rating comes from: Critic or User + /// public RatingAuthority Authority { get; set; } = RatingAuthority.User; public string? ProviderUrl { get; set; } public int SeriesId { get; set; } diff --git a/API/Services/Plus/WantToReadSyncService.cs b/API/Services/Plus/WantToReadSyncService.cs index 07861710c..a6d536911 100644 --- a/API/Services/Plus/WantToReadSyncService.cs +++ b/API/Services/Plus/WantToReadSyncService.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using API.Data; using API.Data.Repositories; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Recommendation; using API.DTOs.SeriesDetail; using API.Entities; diff --git a/API/Startup.cs b/API/Startup.cs index 188c2b2dd..34af22154 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -138,8 +138,8 @@ public class Startup { c.SwaggerDoc("v1", new OpenApiInfo { - Version = "3.1.0", - Title = $"Kavita (v{BuildInfo.Version})", + Version = BuildInfo.Version.ToString(), + Title = $"Kavita", Description = $"Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v{BuildInfo.Version}", License = new OpenApiLicense { diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 1cf4e448e..fd24bd9ff 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -536,7 +536,7 @@ export class ActionService { addMultipleSeriesToWantToReadList(seriesIds: Array, callback?: VoidActionCallback) { this.memberService.addSeriesToWantToRead(seriesIds).subscribe(() => { - this.toastr.success('Series added to Want to Read list'); + this.toastr.success(translate('toasts.series-added-want-to-read')); if (callback) { callback(); } 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 ea08f2262..6d39b0b28 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 @@ -22,7 +22,7 @@
- @if (series.localizedName !== series.name) { + @if (series.localizedName !== series.name && series.localizedName) { {{series.localizedName | defaultValue}} }
diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 08ac0515c..e460e3ffa 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -2606,6 +2606,7 @@ "entity-unread": "{{name}} is now unread", "mark-read": "Marked as Read", "mark-unread": "Marked as Unread", + "series-added-want-to-read": "Series added from Want to Read list", "series-removed-want-to-read": "Series removed from Want to Read list", "series-deleted": "Series deleted", "delete-review": "Are you sure you want to delete your review?", From 7e4216d0bf11b650638405897fa9c8aac1ee4f56 Mon Sep 17 00:00:00 2001 From: majora2007 Date: Sun, 4 May 2025 14:15:27 +0000 Subject: [PATCH 05/57] Bump versions by dotnet-bump-version. --- Kavita.Common/Kavita.Common.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 79410fe0f..2c9ab6dc0 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -3,7 +3,7 @@ net9.0 kavitareader.com Kavita - 0.8.6.7 + 0.8.6.8 en true From cd2a6af6f2368207f2580a963960045dc09468c8 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 4 May 2025 14:16:39 +0000 Subject: [PATCH 06/57] Update OpenAPI documentation --- openapi.json | 164 +++++++++++---------------------------------------- 1 file changed, 36 insertions(+), 128 deletions(-) diff --git a/openapi.json b/openapi.json index 58991a19e..11a839f53 100644 --- a/openapi.json +++ b/openapi.json @@ -1,13 +1,13 @@ { "openapi": "3.0.4", "info": { - "title": "Kavita (v0.8.6.6)", - "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.6.6", + "title": "Kavita", + "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.6.7", "license": { "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "3.1.0" + "version": "0.8.6.7" }, "servers": [ { @@ -17115,18 +17115,15 @@ }, "range": { "type": "string", - "description": "Range of chapters. Chapter 2-4 -> \"2-4\". Chapter 2 -> \"2\". If special, will be special name.", "nullable": true }, "number": { "type": "string", - "description": "Smallest number of the Range.", "nullable": true, "deprecated": true }, "minNumber": { "type": "number", - "description": "This may be 0 under the circumstance that the Issue is \"Alpha\" or other non-standard numbers.", "format": "float" }, "maxNumber": { @@ -17135,21 +17132,17 @@ }, "sortOrder": { "type": "number", - "description": "The sorting order of the Chapter. Inherits from MinNumber, but can be overridden.", "format": "float" }, "pages": { "type": "integer", - "description": "Total number of pages in all MangaFiles", "format": "int32" }, "isSpecial": { - "type": "boolean", - "description": "If this Chapter contains files that could only be identified as Series or has Special Identifier from filename" + "type": "boolean" }, "title": { "type": "string", - "description": "Used for books/specials to display custom title. For non-specials/books, will be set to API.DTOs.ChapterDto.Range", "nullable": true }, "files": { @@ -17176,17 +17169,14 @@ "format": "date-time" }, "coverImageLocked": { - "type": "boolean", - "description": "If the Cover Image is locked for this entity" + "type": "boolean" }, "volumeId": { "type": "integer", - "description": "Volume Id this Chapter belongs to", "format": "int32" }, "createdUtc": { "type": "string", - "description": "When chapter was created", "format": "date-time" }, "lastModifiedUtc": { @@ -17195,22 +17185,18 @@ }, "created": { "type": "string", - "description": "When chapter was created in local server time", "format": "date-time" }, "releaseDate": { "type": "string", - "description": "When the chapter was released.", "format": "date-time" }, "titleName": { "type": "string", - "description": "Title of the Chapter/Issue", "nullable": true }, "summary": { "type": "string", - "description": "Summary of the Chapter", "nullable": true }, "ageRating": { @@ -17233,12 +17219,11 @@ -1 ], "type": "integer", - "description": "Age Rating for the issue/chapter", + "description": "Represents Age Rating for content.", "format": "int32" }, "wordCount": { "type": "integer", - "description": "Total words in a Chapter (books only)", "format": "int64" }, "volumeTitle": { @@ -17260,12 +17245,10 @@ }, "webLinks": { "type": "string", - "description": "Comma-separated link of urls to external services that have some relation to the Chapter", "nullable": true }, "isbn": { "type": "string", - "description": "ISBN-13 (usually) of the Chapter", "nullable": true }, "writers": { @@ -17387,17 +17370,14 @@ }, "language": { "type": "string", - "description": "Language for the Chapter/Issue", "nullable": true }, "count": { "type": "integer", - "description": "Number in the TotalCount of issues", "format": "int32" }, "totalCount": { "type": "integer", - "description": "Total number of issues for the series", "format": "int32" }, "languageLocked": { @@ -17407,12 +17387,10 @@ "type": "boolean" }, "ageRatingLocked": { - "type": "boolean", - "description": "Locked by user so metadata updates from scan loop will not override AgeRating" + "type": "boolean" }, "publicationStatusLocked": { - "type": "boolean", - "description": "Locked by user so metadata updates from scan loop will not override PublicationStatus" + "type": "boolean" }, "genresLocked": { "type": "boolean" @@ -18643,6 +18621,7 @@ 1 ], "type": "integer", + "description": "Where this rating comes from: Critic or User", "format": "int32" }, "providerUrl": { @@ -22691,7 +22670,6 @@ }, "lastChapterAdded": { "type": "string", - "description": "DateTime representing last time a chapter was added to the Series", "format": "date-time" }, "userRating": { @@ -22719,9 +22697,6 @@ "type": "string", "format": "date-time" }, - "nameLocked": { - "type": "boolean" - }, "sortNameLocked": { "type": "boolean" }, @@ -22730,7 +22705,6 @@ }, "wordCount": { "type": "integer", - "description": "Total number of words for the series. Only applies to epubs.", "format": "int64" }, "libraryId": { @@ -22755,26 +22729,21 @@ }, "folderPath": { "type": "string", - "description": "The highest level folder for this Series", "nullable": true }, "lowestFolderPath": { "type": "string", - "description": "Lowest path (that is under library root) that contains all files for the series.", "nullable": true }, "lastFolderScanned": { "type": "string", - "description": "The last time the folder for this series was scanned", "format": "date-time" }, "dontMatch": { - "type": "boolean", - "description": "Do not match the series with any external Metadata service. This will automatically opt it out of scrobbling." + "type": "boolean" }, "isBlacklisted": { - "type": "boolean", - "description": "If the series was unable to match, it will be blacklisted until a manual metadata match overrides it" + "type": "boolean" }, "coverImage": { "type": "string", @@ -24023,18 +23992,15 @@ }, "range": { "type": "string", - "description": "Range of chapters. Chapter 2-4 -> \"2-4\". Chapter 2 -> \"2\". If special, will be special name.", "nullable": true }, "number": { "type": "string", - "description": "Smallest number of the Range.", "nullable": true, "deprecated": true }, "minNumber": { "type": "number", - "description": "This may be 0 under the circumstance that the Issue is \"Alpha\" or other non-standard numbers.", "format": "float" }, "maxNumber": { @@ -24043,21 +24009,17 @@ }, "sortOrder": { "type": "number", - "description": "The sorting order of the Chapter. Inherits from MinNumber, but can be overridden.", "format": "float" }, "pages": { "type": "integer", - "description": "Total number of pages in all MangaFiles", "format": "int32" }, "isSpecial": { - "type": "boolean", - "description": "If this Chapter contains files that could only be identified as Series or has Special Identifier from filename" + "type": "boolean" }, "title": { "type": "string", - "description": "Used for books/specials to display custom title. For non-specials/books, will be set to API.DTOs.ChapterDto.Range", "nullable": true }, "files": { @@ -24084,17 +24046,14 @@ "format": "date-time" }, "coverImageLocked": { - "type": "boolean", - "description": "If the Cover Image is locked for this entity" + "type": "boolean" }, "volumeId": { "type": "integer", - "description": "Volume Id this Chapter belongs to", "format": "int32" }, "createdUtc": { "type": "string", - "description": "When chapter was created", "format": "date-time" }, "lastModifiedUtc": { @@ -24103,22 +24062,18 @@ }, "created": { "type": "string", - "description": "When chapter was created in local server time", "format": "date-time" }, "releaseDate": { "type": "string", - "description": "When the chapter was released.", "format": "date-time" }, "titleName": { "type": "string", - "description": "Title of the Chapter/Issue", "nullable": true }, "summary": { "type": "string", - "description": "Summary of the Chapter", "nullable": true }, "ageRating": { @@ -24141,12 +24096,11 @@ -1 ], "type": "integer", - "description": "Age Rating for the issue/chapter", + "description": "Represents Age Rating for content.", "format": "int32" }, "wordCount": { "type": "integer", - "description": "Total words in a Chapter (books only)", "format": "int64" }, "minHoursToRead": { @@ -24163,12 +24117,10 @@ }, "webLinks": { "type": "string", - "description": "Comma-separated link of urls to external services that have some relation to the Chapter", "nullable": true }, "isbn": { "type": "string", - "description": "ISBN-13 (usually) of the Chapter", "nullable": true }, "writers": { @@ -24290,17 +24242,14 @@ }, "language": { "type": "string", - "description": "Language for the Chapter/Issue", "nullable": true }, "count": { "type": "integer", - "description": "Number in the TotalCount of issues", "format": "int32" }, "totalCount": { "type": "integer", - "description": "Total number of issues for the series", "format": "int32" }, "languageLocked": { @@ -24310,12 +24259,10 @@ "type": "boolean" }, "ageRatingLocked": { - "type": "boolean", - "description": "Locked by user so metadata updates from scan loop will not override AgeRating" + "type": "boolean" }, "publicationStatusLocked": { - "type": "boolean", - "description": "Locked by user so metadata updates from scan loop will not override PublicationStatus" + "type": "boolean" }, "genresLocked": { "type": "boolean" @@ -25542,6 +25489,7 @@ "items": { "type": "string" }, + "description": "List of Roles to assign to user. If admin not present, Pleb will be applied.\nIf admin present, all libraries will be granted access and will ignore those from DTO.", "nullable": true }, "libraries": { @@ -25558,7 +25506,6 @@ }, "email": { "type": "string", - "description": "Email of the user", "nullable": true } }, @@ -25709,7 +25656,6 @@ "locale", "noTransitions", "pageSplitOption", - "pdfLayoutMode", "pdfScrollMode", "pdfSpreadMode", "pdfTheme", @@ -25730,7 +25676,6 @@ 1 ], "type": "integer", - "description": "Manga Reader Option: What direction should the next/prev page buttons go", "format": "int32" }, "scalingOption": { @@ -25741,7 +25686,6 @@ 3 ], "type": "integer", - "description": "Manga Reader Option: How should the image be scaled to screen", "format": "int32" }, "pageSplitOption": { @@ -25752,7 +25696,6 @@ 3 ], "type": "integer", - "description": "Manga Reader Option: Which side of a split image should we show first", "format": "int32" }, "readerMode": { @@ -25762,7 +25705,6 @@ 2 ], "type": "integer", - "description": "Manga Reader Option: How the manga reader should perform paging or reading of the file\n\nWebtoon uses scrolling to page, LeftRight uses paging by clicking left/right side of reader, UpDown uses paging\nby clicking top/bottom sides of reader.\n", "format": "int32" }, "layoutMode": { @@ -25772,57 +25714,45 @@ 3 ], "type": "integer", - "description": "Manga Reader Option: How many pages to display in the reader at once", "format": "int32" }, "emulateBook": { - "type": "boolean", - "description": "Manga Reader Option: Emulate a book by applying a shadow effect on the pages" + "type": "boolean" }, "backgroundColor": { "minLength": 1, - "type": "string", - "description": "Manga Reader Option: Background color of the reader" + "type": "string" }, "swipeToPaginate": { - "type": "boolean", - "description": "Manga Reader Option: Should swiping trigger pagination" + "type": "boolean" }, "autoCloseMenu": { - "type": "boolean", - "description": "Manga Reader Option: Allow the menu to close after 6 seconds without interaction" + "type": "boolean" }, "showScreenHints": { - "type": "boolean", - "description": "Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change" + "type": "boolean" }, "allowAutomaticWebtoonReaderDetection": { - "type": "boolean", - "description": "Manga Reader Option: Allow Automatic Webtoon detection" + "type": "boolean" }, "bookReaderMargin": { "type": "integer", - "description": "Book Reader Option: Override extra Margin", "format": "int32" }, "bookReaderLineSpacing": { "type": "integer", - "description": "Book Reader Option: Override line-height", "format": "int32" }, "bookReaderFontSize": { "type": "integer", - "description": "Book Reader Option: Override font size", "format": "int32" }, "bookReaderFontFamily": { "minLength": 1, - "type": "string", - "description": "Book Reader Option: Maps to the default Kavita font-family (inherit) or an override" + "type": "string" }, "bookReaderTapToPaginate": { - "type": "boolean", - "description": "Book Reader Option: Allows tapping on side of screens to paginate" + "type": "boolean" }, "bookReaderReadingDirection": { "enum": [ @@ -25830,7 +25760,6 @@ 1 ], "type": "integer", - "description": "Book Reader Option: What direction should the next/prev page buttons go", "format": "int32" }, "bookReaderWritingStyle": { @@ -25839,7 +25768,7 @@ 1 ], "type": "integer", - "description": "Book Reader Option: What writing style should be used, horizontal or vertical.", + "description": "Represents the writing styles for the book-reader", "format": "int32" }, "theme": { @@ -25859,8 +25788,7 @@ "format": "int32" }, "bookReaderImmersiveMode": { - "type": "boolean", - "description": "Book Reader Option: A flag that hides the menu-ing system behind a click on the screen. This should be used with tap to paginate, but the app doesn't enforce this." + "type": "boolean" }, "globalPageLayoutMode": { "enum": [ @@ -25868,33 +25796,26 @@ 1 ], "type": "integer", - "description": "Global Site Option: If the UI should layout items as Cards or List items", "format": "int32" }, "blurUnreadSummaries": { - "type": "boolean", - "description": "UI Site Global Setting: If unread summaries should be blurred until expanded or unless user has read it already" + "type": "boolean" }, "promptForDownloadSize": { - "type": "boolean", - "description": "UI Site Global Setting: Should Kavita prompt user to confirm downloads that are greater than 100 MB." + "type": "boolean" }, "noTransitions": { - "type": "boolean", - "description": "UI Site Global Setting: Should Kavita disable CSS transitions" + "type": "boolean" }, "collapseSeriesRelationships": { - "type": "boolean", - "description": "When showing series, only parent series or series with no relationships will be returned" + "type": "boolean" }, "shareReviews": { - "type": "boolean", - "description": "UI Site Global Setting: Should series reviews be shared with all users in the server" + "type": "boolean" }, "locale": { "minLength": 1, - "type": "string", - "description": "UI Site Global Setting: The language locale that should be used for the user" + "type": "string" }, "pdfTheme": { "enum": [ @@ -25902,7 +25823,6 @@ 1 ], "type": "integer", - "description": "PDF Reader: Theme of the Reader", "format": "int32" }, "pdfScrollMode": { @@ -25912,16 +25832,7 @@ 3 ], "type": "integer", - "description": "PDF Reader: Scroll mode of the reader", - "format": "int32" - }, - "pdfLayoutMode": { - "enum": [ - 0, - 2 - ], - "type": "integer", - "description": "PDF Reader: Layout Mode of the reader", + "description": "Enum values match PdfViewer's enums", "format": "int32" }, "pdfSpreadMode": { @@ -25931,16 +25842,13 @@ 2 ], "type": "integer", - "description": "PDF Reader: Spread Mode of the reader", "format": "int32" }, "aniListScrobblingEnabled": { - "type": "boolean", - "description": "Kavita+: Should this account have Scrobbling enabled for AniList" + "type": "boolean" }, "wantToReadSync": { - "type": "boolean", - "description": "Kavita+: Should this account have Want to Read Sync enabled" + "type": "boolean" } }, "additionalProperties": false From 85b3187f3faa497b1e51e1eac138cff901974f46 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Wed, 7 May 2025 16:58:39 -0500 Subject: [PATCH 07/57] Added some notes --- API/Services/Plus/ExternalMetadataService.cs | 1 + API/Services/SeriesService.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index f9af923a2..3f44f56d3 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -752,6 +752,7 @@ public class ExternalMetadataService : IExternalMetadataService { Name = w.Name, AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListCharacterWebsite), + // Can I tag links to resolve favicon? Need to parse html, rewrite the urls, then have a Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), }) .Concat(series.Metadata.People diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 805b3b06f..34c55e6ce 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -633,6 +633,7 @@ public class SeriesService : ISeriesService public async Task FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string chapterRange, string? chapterTitle, bool withHash) { + // TODO: Refactor so this is unit testable if (string.IsNullOrEmpty(chapterTitle) && (isSpecial || libraryType == LibraryType.Book)) throw new ArgumentException("Chapter Title cannot be null"); if (isSpecial) From 16498d4b4056a418ed37825712ddeba723379197 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Wed, 7 May 2025 19:10:54 -0500 Subject: [PATCH 08/57] Added a bunch of basic json files for the different structures Kavita supports. Unit tests to come. --- .../Services/Scanner/FileSystemParserTests.cs | 54 +++++++++++++++++++ .../ScannerService/TestCases/Base/ideal.json | 5 ++ .../TestCases/Base/localised-name-merge.json | 6 +++ .../TestCases/Base/loose-books.json | 4 ++ .../Base/loose-images-chapter-folders.json | 5 ++ .../Base/loose-images-mixed-folders.json | 5 ++ .../Base/loose-images-nested-folders.json | 5 ++ .../TestCases/Base/loose-images-pages.json | 4 ++ .../Base/loose-images-volume-folders.json | 5 ++ .../TestCases/Base/loose-series.json | 5 ++ .../TestCases/Base/publisher.json | 6 +++ .../TestCases/Base/special-folder.json | 6 +++ .../TestCases/Base/special-marker.json | 6 +++ .../TestCases/Exclude/exclude-file.json | 6 +++ .../Exclude/exclude-subfolder-all.json | 6 +++ .../TestCases/Exclude/exclude-subfolder.json | 6 +++ 16 files changed, 134 insertions(+) create mode 100644 API.Tests/Services/Scanner/FileSystemParserTests.cs create mode 100644 API.Tests/Services/Test Data/ScannerService/TestCases/Base/ideal.json create mode 100644 API.Tests/Services/Test Data/ScannerService/TestCases/Base/localised-name-merge.json create mode 100644 API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-books.json create mode 100644 API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-images-chapter-folders.json create mode 100644 API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-images-mixed-folders.json create mode 100644 API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-images-nested-folders.json create mode 100644 API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-images-pages.json create mode 100644 API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-images-volume-folders.json create mode 100644 API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-series.json create mode 100644 API.Tests/Services/Test Data/ScannerService/TestCases/Base/publisher.json create mode 100644 API.Tests/Services/Test Data/ScannerService/TestCases/Base/special-folder.json create mode 100644 API.Tests/Services/Test Data/ScannerService/TestCases/Base/special-marker.json create mode 100644 API.Tests/Services/Test Data/ScannerService/TestCases/Exclude/exclude-file.json create mode 100644 API.Tests/Services/Test Data/ScannerService/TestCases/Exclude/exclude-subfolder-all.json create mode 100644 API.Tests/Services/Test Data/ScannerService/TestCases/Exclude/exclude-subfolder.json diff --git a/API.Tests/Services/Scanner/FileSystemParserTests.cs b/API.Tests/Services/Scanner/FileSystemParserTests.cs new file mode 100644 index 000000000..c2067e5d9 --- /dev/null +++ b/API.Tests/Services/Scanner/FileSystemParserTests.cs @@ -0,0 +1,54 @@ +using System.IO; +using System.Threading.Tasks; +using API.Data.Repositories; +using API.Tests.Helpers; +using Hangfire; +using Xunit; +using Xunit.Abstractions; + +namespace API.Tests.Services.Scanner; + +/// +/// Responsible for testing Change Detection, Exclude Patterns, +/// +public class FileSystemParserTests : AbstractDbTest +{ + + private readonly ITestOutputHelper _testOutputHelper; + private readonly ScannerHelper _scannerHelper; + private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests"); + + public FileSystemParserTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + + // Set up Hangfire to use in-memory storage for testing + GlobalConfiguration.Configuration.UseInMemoryStorage(); + _scannerHelper = new ScannerHelper(UnitOfWork, testOutputHelper); + } + + protected override async Task ResetDb() + { + Context.Library.RemoveRange(Context.Library); + await Context.SaveChangesAsync(); + } + + + #region Validate Change Detection + + + [Fact] + public async Task ScanLibrary_ComicVine_PublisherFolder() + { + var testcase = "Publisher - ComicVine.json"; + var library = await _scannerHelper.GenerateScannerData(testcase); + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Equal(4, postLib.Series.Count); + } + + #endregion +} diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Base/ideal.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/ideal.json new file mode 100644 index 000000000..df3b1bbbd --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/ideal.json @@ -0,0 +1,5 @@ +[ + "Root 1/Series A/Series A 01.cbz", + "Root 1/Series B/Series B 01.cbz", + "Root 2/Series C/Series C 01.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Base/localised-name-merge.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/localised-name-merge.json new file mode 100644 index 000000000..77addadef --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/localised-name-merge.json @@ -0,0 +1,6 @@ +[ + "Root 1/Series A/Series A/Series A 01.cbz", + "Root 1/Series A/Series A1/Series A1 01.cbz", + "Root 1/Series B/Series B 01.cbz", + "Root 2/Series C/Series C 01.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-books.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-books.json new file mode 100644 index 000000000..b2b274081 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-books.json @@ -0,0 +1,4 @@ +[ + "Root 1/Books/book v1.pdf", + "Root 1/Books/book v2.pdf" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-images-chapter-folders.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-images-chapter-folders.json new file mode 100644 index 000000000..3c1cd10de --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-images-chapter-folders.json @@ -0,0 +1,5 @@ +[ + "Root 1/Books/Series A/ch 1/001.png", + "Root 1/Books/Series A/ch 1/002.png", + "Root 1/Books/Series A/ch 2/001.png" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-images-mixed-folders.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-images-mixed-folders.json new file mode 100644 index 000000000..74e288162 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-images-mixed-folders.json @@ -0,0 +1,5 @@ +[ + "Root 1/Books/Series A/vol 1 ch 1/001.png", + "Root 1/Books/Series A/vol 1 ch 2/002.png", + "Root 1/Books/Series A/vol 2/001.png" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-images-nested-folders.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-images-nested-folders.json new file mode 100644 index 000000000..8fed641e6 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-images-nested-folders.json @@ -0,0 +1,5 @@ +[ + "Root 1/Books/Series A/vol 1/ch 1/001.png", + "Root 1/Books/Series A/vol 1/ch 2/002.png", + "Root 1/Books/Series A/vol 2/001.png" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-images-pages.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-images-pages.json new file mode 100644 index 000000000..87b70eba0 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-images-pages.json @@ -0,0 +1,4 @@ +[ + "Root 1/Books/Series A [Digital]/001.png", + "Root 1/Books/Series A [Digital]/002.png" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-images-volume-folders.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-images-volume-folders.json new file mode 100644 index 000000000..2055a276e --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-images-volume-folders.json @@ -0,0 +1,5 @@ +[ + "Root 1/Books/Series A/vol 1/001.png", + "Root 1/Books/Series A/vol 1/002.png", + "Root 1/Books/Series A/vol 2/001.png" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-series.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-series.json new file mode 100644 index 000000000..d4a749afe --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/loose-series.json @@ -0,0 +1,5 @@ +[ + "Root 1/Genre/Series A 01.cbz", + "Root 1/Genre/Series B 01.cbz", + "Root 1/Genre/Series C 01.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Base/publisher.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/publisher.json new file mode 100644 index 000000000..8abfa3728 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/publisher.json @@ -0,0 +1,6 @@ +[ + "Root 1/Publisher 1/Series A/Series A #1.cbz", + "Root 1/Publisher 1/Series B/Series B #1.cbz", + "Root 1/Publisher 2/Series C/Series C #1.cbz", + "Root 1/Publisher 2/Series D/Series D #1.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Base/special-folder.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/special-folder.json new file mode 100644 index 000000000..fec7691a3 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/special-folder.json @@ -0,0 +1,6 @@ +[ + "Root 1/Series A/Series A 01.cbz", + "Root 1/Series A/Series A 02.cbz", + "Root 1/Series A/Specials/Series A - Title.cbz", + "Root 1/Series A/Specials/Title Two.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Base/special-marker.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/special-marker.json new file mode 100644 index 000000000..e79249c78 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Base/special-marker.json @@ -0,0 +1,6 @@ +[ + "Root 1/Series A/Series A 01.cbz", + "Root 1/Series A/Series A 02.cbz", + "Root 1/Series A/Series A - Title SP01.cbz", + "Root 1/Series A/Series A - Title SP02.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude/exclude-file.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude/exclude-file.json new file mode 100644 index 000000000..9b93ebfff --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude/exclude-file.json @@ -0,0 +1,6 @@ +[ + "Root 1/Series A/Series A 01.cbz", + "Root 1/Series B/Series B 01.cbz", + "Root 1/Series B/Series B 02.cbz", + "Root 2/Series C/Series C 01.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude/exclude-subfolder-all.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude/exclude-subfolder-all.json new file mode 100644 index 000000000..ea3744f6f --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude/exclude-subfolder-all.json @@ -0,0 +1,6 @@ +[ + "Root 1/Series A/Series A 01.cbz", + "Root 1/Series B/Series B 01.cbz", + "Root 1/Series B/Ignore/Series B 02.cbz", + "Root 2/Ignore/Series C/Series C 01.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude/exclude-subfolder.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude/exclude-subfolder.json new file mode 100644 index 000000000..a85e342fc --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude/exclude-subfolder.json @@ -0,0 +1,6 @@ +[ + "Root 1/Series A/Series A 01.cbz", + "Root 1/Series B/Series B 01.cbz", + "Root 1/Series B/Ignore/Series B 02.cbz", + "Root 2/Series C/Series C 01.cbz" +] From 4372d09ee49533461fcc00cc38bfd8e30b6108a9 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Thu, 8 May 2025 16:49:33 -0500 Subject: [PATCH 09/57] Added the first part of the new scanner - file scanner. Responsible for walking all directories and finding all files. --- API.Tests/Helpers/ScannerHelper.cs | 20 ++- API.Tests/Services/FileScannerTests.cs | 156 ++++++++++++++++++ API.Tests/Services/ScannerServiceTests.cs | 3 - .../TestCases/Mixed Formats - Manga.json | 8 + API/DTOs/Internal/Scanner/ScannedDirectory.cs | 22 +++ API/DTOs/Internal/Scanner/ScannedFile.cs | 14 ++ API/DTOs/Internal/Scanner/ScannerOption.cs | 25 +++ API/Services/Tasks/Scanner/FileScanner.cs | 145 ++++++++++++++++ 8 files changed, 381 insertions(+), 12 deletions(-) create mode 100644 API.Tests/Services/FileScannerTests.cs create mode 100644 API.Tests/Services/Test Data/ScannerService/TestCases/Mixed Formats - Manga.json create mode 100644 API/DTOs/Internal/Scanner/ScannedDirectory.cs create mode 100644 API/DTOs/Internal/Scanner/ScannedFile.cs create mode 100644 API/DTOs/Internal/Scanner/ScannerOption.cs create mode 100644 API/Services/Tasks/Scanner/FileScanner.cs diff --git a/API.Tests/Helpers/ScannerHelper.cs b/API.Tests/Helpers/ScannerHelper.cs index 653efebb1..150850f99 100644 --- a/API.Tests/Helpers/ScannerHelper.cs +++ b/API.Tests/Helpers/ScannerHelper.cs @@ -35,7 +35,7 @@ public class ScannerHelper private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests"); private readonly string _testcasesDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/TestCases"); private readonly string _imagePath = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/1x1.png"); - private static readonly string[] ComicInfoExtensions = new[] { ".cbz", ".cbr", ".zip", ".rar" }; + private static readonly string[] ComicInfoExtensions = [".cbz", ".cbr", ".zip", ".rar"]; public ScannerHelper(IUnitOfWork unitOfWork, ITestOutputHelper testOutputHelper) { @@ -43,7 +43,7 @@ public class ScannerHelper _testOutputHelper = testOutputHelper; } - public async Task GenerateScannerData(string testcase, Dictionary comicInfos = null) + public async Task GenerateScannerData(string testcase, Dictionary? comicInfos = null) { var testDirectoryPath = await GenerateTestDirectory(Path.Join(_testcasesDirectory, testcase), comicInfos); @@ -64,7 +64,7 @@ public class ScannerHelper return library; } - public ScannerService CreateServices(DirectoryService ds = null, IFileSystem fs = null) + public ScannerService CreateServices(DirectoryService? ds = null, IFileSystem? fs = null) { fs ??= new FileSystem(); ds ??= new DirectoryService(Substitute.For>(), fs); @@ -113,7 +113,7 @@ public class ScannerHelper - private async Task GenerateTestDirectory(string mapPath, Dictionary comicInfos = null) + private async Task GenerateTestDirectory(string mapPath, Dictionary? comicInfos = null) { // Read the map file var mapContent = await File.ReadAllTextAsync(mapPath); @@ -130,7 +130,7 @@ public class ScannerHelper Directory.CreateDirectory(testDirectory); // Generate the files and folders - await Scaffold(testDirectory, filePaths, comicInfos); + await Scaffold(testDirectory, filePaths ?? [], comicInfos); _testOutputHelper.WriteLine($"Test Directory Path: {testDirectory}"); @@ -138,18 +138,20 @@ public class ScannerHelper } - public async Task Scaffold(string testDirectory, List filePaths, Dictionary comicInfos = null) + public async Task Scaffold(string testDirectory, List filePaths, Dictionary? comicInfos = null) { foreach (var relativePath in filePaths) { var fullPath = Path.Combine(testDirectory, relativePath); var fileDir = Path.GetDirectoryName(fullPath); + if (string.IsNullOrEmpty(fileDir)) continue; + // Create the directory if it doesn't exist if (!Directory.Exists(fileDir)) { Directory.CreateDirectory(fileDir); - Console.WriteLine($"Created directory: {fileDir}"); + _testOutputHelper.WriteLine($"Created directory: {fileDir}"); } var ext = Path.GetExtension(fullPath).ToLower(); @@ -161,7 +163,7 @@ public class ScannerHelper { // Create an empty file await File.Create(fullPath).DisposeAsync(); - Console.WriteLine($"Created empty file: {fullPath}"); + _testOutputHelper.WriteLine($"Created empty file: {fullPath}"); } } } @@ -188,7 +190,7 @@ public class ScannerHelper } } - Console.WriteLine($"Created minimal CBZ archive: {filePath} with{(comicInfo != null ? "" : "out")} metadata."); + _testOutputHelper.WriteLine($"Created minimal CBZ archive: {filePath} with{(comicInfo != null ? "" : "out")} metadata."); } diff --git a/API.Tests/Services/FileScannerTests.cs b/API.Tests/Services/FileScannerTests.cs new file mode 100644 index 000000000..103c46c7c --- /dev/null +++ b/API.Tests/Services/FileScannerTests.cs @@ -0,0 +1,156 @@ +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Internal.Scanner; +using API.Entities.Enums; +using API.Services; +using API.Services.Tasks.Scanner; +using API.Services.Tasks.Scanner.Parser; +using API.Tests.Helpers; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; +using Xunit.Abstractions; + +namespace API.Tests.Services; + +public class FileScannerTests : AbstractDbTest +{ + private readonly FileScanner _fileScanner; + private readonly IDirectoryService _directoryService; + private readonly ScannerHelper _scannerHelper; + private readonly string _outputDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests"); + private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/TestCases"); + + public FileScannerTests(ITestOutputHelper testOutputHelper) + { + _directoryService = new DirectoryService(Substitute.For>(), new FileSystem()); + _fileScanner = new FileScanner(_directoryService, UnitOfWork); + _scannerHelper = new ScannerHelper(UnitOfWork, testOutputHelper); + } + + #region ScanFiles - Basic Tests + + /// + /// Validates that FileTypePattern works + /// + [Fact] + public async Task ScanFiles_ShouldIncludeOnlyArchiveTypes() + { + const string testcase = "Flat Series - Manga.json"; + var library = await _scannerHelper.GenerateScannerData(testcase); + var folder = library.Folders.First().Path; + + var options = new ScannerOption + { + FolderPaths = [folder], + FileTypePattern = [FileTypeGroup.Archive], + ExcludePatterns = [] + }; + + var result = _fileScanner.ScanFiles(options); + + Assert.Single(result); // One folder + var scanned = result[0]; + Assert.Equal(Parser.NormalizePath(Path.Join(folder, "My Dress-Up Darling")), scanned.DirectoryPath); + Assert.All(scanned.Files, file => + { + Assert.EndsWith(".cbz", file.FilePath); + }); + } + + [Fact] + public async Task ScanFiles_ShouldIncludeMultipleTypes() + { + const string testcase = "Mixed Formats - Manga.json"; + var library = await _scannerHelper.GenerateScannerData(testcase); + var folder = library.Folders.First().Path; + + var options = new ScannerOption + { + FolderPaths = [folder], + FileTypePattern = [FileTypeGroup.Archive, FileTypeGroup.Epub], + ExcludePatterns = [] + }; + + var result = _fileScanner.ScanFiles(options); + + Assert.Single(result); // One folder + var scanned = result[0]; + Assert.Equal(Parser.NormalizePath(Path.Join(folder, "My Dress-Up Darling")), scanned.DirectoryPath); + var validExtensions = new[] { ".cbz", ".epub" }; + Assert.All(scanned.Files, file => + { + Assert.Contains(Path.GetExtension(file.FilePath)?.ToLowerInvariant(), validExtensions); + }); + } + + + + + #endregion + + #region ScannFiles - Exclude Patterns + + + [Fact] + public async Task ScanFiles_ShouldExcludeMatchingPattern() + { + const string testcase = "Flat Series - Manga.json"; + var library = await _scannerHelper.GenerateScannerData(testcase); + var folder = library.Folders.First().Path; + + var options = new ScannerOption + { + FolderPaths = [folder], + FileTypePattern = [FileTypeGroup.Archive], + ExcludePatterns = ["*ch 10.cbz"] // Exclude chapter 10 + }; + + var result = _fileScanner.ScanFiles(options); + + var scannedFiles = result.SelectMany(d => d.Files).ToList(); + Assert.DoesNotContain(scannedFiles, f => f.FilePath.Contains("ch 10.cbz")); + Assert.Contains(scannedFiles, f => f.FilePath.Contains("v01.cbz")); + Assert.Contains(scannedFiles, f => f.FilePath.Contains("v02.cbz")); + } + + #endregion + + #region ScannFiles - Change Detection + + [Fact] + public async Task ScanFiles_ShouldHaveAccurateLastModifiedUtc() + { + const string testcase = "Flat Series - Manga.json"; + var library = await _scannerHelper.GenerateScannerData(testcase); + var folder = library.Folders.First().Path; + + var options = new ScannerOption + { + FolderPaths = [folder], + FileTypePattern = [FileTypeGroup.Archive], + ExcludePatterns = [] + }; + + var result = _fileScanner.ScanFiles(options); + + Assert.Single(result); + var scannedDir = result[0]; + var file = scannedDir.Files[0]; + + var expected = _directoryService.GetLastWriteTime(file.FilePath).ToUniversalTime(); + Assert.Equal(expected, file.LastModifiedUtc); + } + + #endregion + + + protected override async Task ResetDb() + { + Context.Series.RemoveRange(Context.Series); + Context.Library.RemoveRange(Context.Library); + await Context.SaveChangesAsync(); + } +} diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 9b0271fc2..bc9b36843 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -18,14 +18,11 @@ namespace API.Tests.Services; public class ScannerServiceTests : AbstractDbTest { - private readonly ITestOutputHelper _testOutputHelper; private readonly ScannerHelper _scannerHelper; private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests"); public ScannerServiceTests(ITestOutputHelper testOutputHelper) { - _testOutputHelper = testOutputHelper; - // Set up Hangfire to use in-memory storage for testing GlobalConfiguration.Configuration.UseInMemoryStorage(); _scannerHelper = new ScannerHelper(UnitOfWork, testOutputHelper); diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Mixed Formats - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Mixed Formats - Manga.json new file mode 100644 index 000000000..942c015f4 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Mixed Formats - Manga.json @@ -0,0 +1,8 @@ +[ + "My Dress-Up Darling/My Dress-Up Darling v01.cbz", + "My Dress-Up Darling/My Dress-Up Darling v02.cbz", + "My Dress-Up Darling/My Dress-Up Darling ch 10.cbz", + "My Dress-Up Darling/My Dress-Up Darling ch 11.epub", + "My Dress-Up Darling/My Dress-Up Darling ch 12.png", + "My Dress-Up Darling/My Dress-Up Darling ch 13.pdf" +] \ No newline at end of file diff --git a/API/DTOs/Internal/Scanner/ScannedDirectory.cs b/API/DTOs/Internal/Scanner/ScannedDirectory.cs new file mode 100644 index 000000000..1f7df5643 --- /dev/null +++ b/API/DTOs/Internal/Scanner/ScannedDirectory.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using API.Entities.Enums; +using API.Services.Tasks.Scanner.Parser; + +namespace API.DTOs.Internal.Scanner; + +/// +/// Represents a Directory on disk and metadata information for the Scan +/// +public sealed record ScannedDirectory +{ + /// + /// Normalized Directory Path + /// + public required string DirectoryPath { get => _directoryPath; set => _directoryPath = Parser.NormalizePath(value); } + private string _directoryPath; + + public required DateTime LastModifiedUtc { get; set; } + + public List Files { get; set; } = []; +} diff --git a/API/DTOs/Internal/Scanner/ScannedFile.cs b/API/DTOs/Internal/Scanner/ScannedFile.cs new file mode 100644 index 000000000..61c7c60d1 --- /dev/null +++ b/API/DTOs/Internal/Scanner/ScannedFile.cs @@ -0,0 +1,14 @@ +using System; +using API.Entities.Enums; +using API.Services.Tasks.Scanner.Parser; + +namespace API.DTOs.Internal.Scanner; + +public sealed record ScannedFile +{ + public required string FilePath { get => _filePath; set => _filePath = Parser.NormalizePath(value); } + private string _filePath; + + public required DateTime LastModifiedUtc { get; set; } + public required MangaFormat Format { get; set; } +} diff --git a/API/DTOs/Internal/Scanner/ScannerOption.cs b/API/DTOs/Internal/Scanner/ScannerOption.cs new file mode 100644 index 000000000..556f9ae06 --- /dev/null +++ b/API/DTOs/Internal/Scanner/ScannerOption.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using API.Entities.Enums; + +namespace API.DTOs.Internal.Scanner; + +public sealed record ScannerOption +{ + /// + /// A list of File Type Patterns to search files for. If empty, scan will abort + /// + public List FileTypePattern { get; set; } = [FileTypeGroup.Archive, FileTypeGroup.Epub, FileTypeGroup.Images, FileTypeGroup.Pdf]; + /// + /// Folders to scan + /// + public List FolderPaths { get; set; } + + /// + /// Glob syntax to exclude from scan results + /// + public List ExcludePatterns { get; set; } = []; + /// + /// Skip LastModified checks + /// + public bool ForceScan { get; set; } +} diff --git a/API/Services/Tasks/Scanner/FileScanner.cs b/API/Services/Tasks/Scanner/FileScanner.cs new file mode 100644 index 000000000..f29e57d61 --- /dev/null +++ b/API/Services/Tasks/Scanner/FileScanner.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs.Internal.Scanner; +using API.Entities.Enums; +using API.Extensions; +using Kavita.Common.Helpers; + +namespace API.Services.Tasks.Scanner; + +public interface IFileScanner +{ + // TODO: Move this to the scanner service + //Task ScanLibrary(int libraryId, bool forceScan = false); + List ScanFiles(ScannerOption options); +} + + +public class FileScanner : IFileScanner +{ + private readonly IDirectoryService _directoryService; + private readonly IUnitOfWork _unitOfWork; + + public FileScanner(IDirectoryService directoryService, IUnitOfWork unitOfWork) + { + _directoryService = directoryService; + _unitOfWork = unitOfWork; + } + + + public async Task ScanLibrary(int libraryId, bool forceScan = false) + { + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, + LibraryIncludes.Folders | LibraryIncludes.ExcludePatterns | LibraryIncludes.FileTypes); + + if (library == null) + { + return; + } + + // Create a ScannerOption + var options = new ScannerOption() + { + FileTypePattern = library.LibraryFileTypes.Select(s => s.FileTypeGroup).ToList(), + ForceScan = forceScan, + ExcludePatterns = [.. library.LibraryExcludePatterns.Select(s => s.Pattern)], + FolderPaths = [.. library.Folders.Select(f => Parser.Parser.NormalizePath(f.Path))] + }; + + + // Find all the information about the directories and their files + var files = ScanFiles(options); + + // Parse said information + + + return; + } + + public List ScanFiles(ScannerOption options) + { + // Validate input options + if (options == null || options.FolderPaths.Count == 0 || options.FileTypePattern.Count == 0) + { + return []; + } + + // Build the file extensions regex from the file type patterns + var fileExtensions = string.Join("|", options.FileTypePattern.Select(l => l.GetRegex())); + if (string.IsNullOrWhiteSpace(fileExtensions)) + { + return []; + } + + + var matcher = BuildMatcher(options.ExcludePatterns); + var scannedDirectories = new List(); + + foreach (var folderPath in options.FolderPaths) + { + var normalizedFolderPath = Parser.Parser.NormalizePath(folderPath); + + var allDirectories = _directoryService.GetAllDirectories(normalizedFolderPath, matcher) + .Select(Parser.Parser.NormalizePath) + .OrderByDescending(d => d.Length) + .ToList(); + + // TODO: Optimization: If allDirectories is large, split into Parallel tasks + + foreach (var directory in allDirectories) + { + var files = _directoryService.ScanFiles(directory, fileExtensions, matcher) + .Select(filePath => + { + // Gather metadata for each file + var lastModifiedUtc = _directoryService.GetLastWriteTime(filePath).ToUniversalTime(); + var format = Parser.Parser.ParseFormat(filePath); + return new ScannedFile + { + FilePath = filePath, + LastModifiedUtc = lastModifiedUtc, + Format = format + }; + }) + .ToList(); + + // Skip directories with no valid files + if (files.Count == 0) + { + continue; + } + + // Get directory's metadata (TODO: Replace with _directoryService.GetLastWriteTime(folder).Truncate(TimeSpan.TicksPerSecond);) + //var directoryLastModifiedUtc = files.Max(f => f.LastModifiedUtc); + var directoryLastModifiedUtc = _directoryService.GetLastWriteTime(normalizedFolderPath).Truncate(TimeSpan.TicksPerSecond); + + // Add the directory and its files to the result + scannedDirectories.Add(new ScannedDirectory + { + DirectoryPath = directory, + LastModifiedUtc = directoryLastModifiedUtc, + Files = files + }); + } + } + + return scannedDirectories; + } + + + + private static GlobMatcher BuildMatcher(List excludePatterns) + { + var matcher = new GlobMatcher(); + foreach (var pattern in excludePatterns.Where(p => !string.IsNullOrEmpty(p))) + { + matcher.AddExclude(pattern); + } + + return matcher; + } +} From 2e53987dcacb770c447d42cada12b99f1c8becfb Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Fri, 9 May 2025 06:35:08 -0500 Subject: [PATCH 10/57] Started working on the parser step - still a bit rough in my head. --- API/Controllers/LibraryController.cs | 6 + API/DTOs/Internal/Scanner/ParsedFile.cs | 12 ++ API/DTOs/Internal/Scanner/ScannedDirectory.cs | 11 +- API/DTOs/Internal/Scanner/ScannedFile.cs | 1 + API/DTOs/Internal/Scanner/ScannerOption.cs | 8 ++ API/Services/Tasks/Scanner/FileParser.cs | 125 ++++++++++++++++++ API/Services/Tasks/Scanner/FileScanner.cs | 57 ++++---- 7 files changed, 190 insertions(+), 30 deletions(-) create mode 100644 API/DTOs/Internal/Scanner/ParsedFile.cs create mode 100644 API/Services/Tasks/Scanner/FileParser.cs diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 4f3b6c832..557ef8fb3 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -623,6 +623,12 @@ public class LibraryController : BaseApiController library.ManageReadingLists = dto.ManageReadingLists; library.AllowScrobbling = dto.AllowScrobbling; library.AllowMetadataMatching = dto.AllowMetadataMatching; + + if (!dto.AllowFilenameParsing && !dto.AllowMetadataParsing) + { + throw new InvalidOperationException("At least one of UseFilenameParsing or UseInternalMetadataParsing must be true."); + } + library.AllowFilenameParsing = dto.AllowFilenameParsing; library.AllowMetadataParsing = dto.AllowMetadataParsing; diff --git a/API/DTOs/Internal/Scanner/ParsedFile.cs b/API/DTOs/Internal/Scanner/ParsedFile.cs new file mode 100644 index 000000000..14499adc8 --- /dev/null +++ b/API/DTOs/Internal/Scanner/ParsedFile.cs @@ -0,0 +1,12 @@ +using API.Data.Metadata; +using API.Services.Tasks.Scanner.Parser; + +namespace API.DTOs.Internal.Scanner; +#nullable enable + +public sealed record ParsedFile +{ + public int Pages { get; set; } + public ComicInfo? Metadata { get; set; } + public ParserInfo? ParsedInformation { get; set; } +} diff --git a/API/DTOs/Internal/Scanner/ScannedDirectory.cs b/API/DTOs/Internal/Scanner/ScannedDirectory.cs index 1f7df5643..2eef705be 100644 --- a/API/DTOs/Internal/Scanner/ScannedDirectory.cs +++ b/API/DTOs/Internal/Scanner/ScannedDirectory.cs @@ -16,7 +16,14 @@ public sealed record ScannedDirectory public required string DirectoryPath { get => _directoryPath; set => _directoryPath = Parser.NormalizePath(value); } private string _directoryPath; - public required DateTime LastModifiedUtc { get; set; } + /// + /// Root where the directory resides + /// + /// Library Root + public required string FolderRoot { get => _folderRoot; set => _folderRoot = Parser.NormalizePath(value); } + private string _folderRoot; - public List Files { get; set; } = []; + public required DateTime LastModifiedUtc { get; init; } + + public List Files { get; init; } = []; } diff --git a/API/DTOs/Internal/Scanner/ScannedFile.cs b/API/DTOs/Internal/Scanner/ScannedFile.cs index 61c7c60d1..abf40ba8a 100644 --- a/API/DTOs/Internal/Scanner/ScannedFile.cs +++ b/API/DTOs/Internal/Scanner/ScannedFile.cs @@ -10,5 +10,6 @@ public sealed record ScannedFile private string _filePath; public required DateTime LastModifiedUtc { get; set; } + public required string FolderRoot { get; set; } public required MangaFormat Format { get; set; } } diff --git a/API/DTOs/Internal/Scanner/ScannerOption.cs b/API/DTOs/Internal/Scanner/ScannerOption.cs index 556f9ae06..a5dfc2f16 100644 --- a/API/DTOs/Internal/Scanner/ScannerOption.cs +++ b/API/DTOs/Internal/Scanner/ScannerOption.cs @@ -22,4 +22,12 @@ public sealed record ScannerOption /// Skip LastModified checks ///
public bool ForceScan { get; set; } + /// + /// Allow use of Filename Parsing + /// + public bool UseFilenameParsing { get; set; } + /// + /// Allow use of Internal Metadata + /// + public bool UseInternalMetadataParsing { get; set; } } diff --git a/API/Services/Tasks/Scanner/FileParser.cs b/API/Services/Tasks/Scanner/FileParser.cs new file mode 100644 index 000000000..91dec5269 --- /dev/null +++ b/API/Services/Tasks/Scanner/FileParser.cs @@ -0,0 +1,125 @@ +using System; +using API.Data.Metadata; +using API.DTOs.Internal.Scanner; +using API.Entities.Enums; +using API.Services.Tasks.Scanner.Parser; +using Microsoft.Extensions.Logging; + +namespace API.Services.Tasks.Scanner; +#nullable enable + +public interface IFileParser +{ + ParsedFile? Parse(ScannedFile file); +} + +public class FileParser : IFileParser +{ + private readonly IArchiveService _archiveService; + private readonly IBookService _bookService; + private readonly IImageService _imageService; + private readonly ILogger _logger; + private readonly BasicParser _basicParser; + private readonly ComicVineParser _comicVineParser; + private readonly ImageParser _imageParser; + private readonly BookParser _bookParser; + private readonly PdfParser _pdfParser; + + public FileParser(IArchiveService archiveService, IDirectoryService directoryService, + IBookService bookService, IImageService imageService, ILogger logger) + { + _archiveService = archiveService; + _bookService = bookService; + _imageService = imageService; + _logger = logger; + + _imageParser = new ImageParser(directoryService); + _basicParser = new BasicParser(directoryService, _imageParser); + _bookParser = new BookParser(directoryService, bookService, _basicParser); + _comicVineParser = new ComicVineParser(directoryService); + _pdfParser = new PdfParser(directoryService); + } + + + + + /// + /// Processes files found during a library scan. + /// + /// Path of a file + /// + /// Library type to determine parsing to perform + // public ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type) + // { + // try + // { + // var info = Parse(path, rootPath, libraryRoot, type); + // if (info == null) + // { + // _logger.LogError("Unable to parse any meaningful information out of file {FilePath}", path); + // return null; + // } + // + // return info; + // } + // catch (Exception ex) + // { + // _logger.LogError(ex, "There was an exception when parsing file {FilePath}", path); + // return null; + // } + // } + + + public ParsedFile? Parse(ScannedFile file, string folderRoot, LibraryType type) + { + var path = file.FilePath; + var rootPath = file.FolderRoot; + + ParserInfo? parserInfo = null; + if (_comicVineParser.IsApplicable(path, type)) + { + parserInfo = _comicVineParser.Parse(path, rootPath, folderRoot, type, GetComicInfo(path)); + } + if (_imageParser.IsApplicable(path, type)) + { + parserInfo = _imageParser.Parse(path, rootPath, folderRoot, type, GetComicInfo(path)); + } + if (_bookParser.IsApplicable(path, type)) + { + parserInfo = _bookParser.Parse(path, rootPath, folderRoot, type, GetComicInfo(path)); + } + if (_pdfParser.IsApplicable(path, type)) + { + parserInfo = _pdfParser.Parse(path, rootPath, folderRoot, type, GetComicInfo(path)); + } + if (_basicParser.IsApplicable(path, type)) + { + parserInfo = _basicParser.Parse(path, rootPath, folderRoot, type, GetComicInfo(path)); + } + + if (parserInfo == null) return null; + + return null; + } + + + /// + /// Gets the ComicInfo for the file if it exists. Null otherwise. + /// + /// Fully qualified path of file + /// + private ComicInfo? GetComicInfo(string filePath) + { + if (Parser.Parser.IsEpub(filePath) || Parser.Parser.IsPdf(filePath)) + { + return _bookService.GetComicInfo(filePath); + } + + if (Parser.Parser.IsComicInfoExtension(filePath)) + { + return _archiveService.GetComicInfo(filePath); + } + + return null; + } +} diff --git a/API/Services/Tasks/Scanner/FileScanner.cs b/API/Services/Tasks/Scanner/FileScanner.cs index f29e57d61..62dce3891 100644 --- a/API/Services/Tasks/Scanner/FileScanner.cs +++ b/API/Services/Tasks/Scanner/FileScanner.cs @@ -31,34 +31,34 @@ public class FileScanner : IFileScanner } - public async Task ScanLibrary(int libraryId, bool forceScan = false) - { - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, - LibraryIncludes.Folders | LibraryIncludes.ExcludePatterns | LibraryIncludes.FileTypes); - - if (library == null) - { - return; - } - - // Create a ScannerOption - var options = new ScannerOption() - { - FileTypePattern = library.LibraryFileTypes.Select(s => s.FileTypeGroup).ToList(), - ForceScan = forceScan, - ExcludePatterns = [.. library.LibraryExcludePatterns.Select(s => s.Pattern)], - FolderPaths = [.. library.Folders.Select(f => Parser.Parser.NormalizePath(f.Path))] - }; - - - // Find all the information about the directories and their files - var files = ScanFiles(options); - - // Parse said information - - - return; - } + // public async Task ScanLibrary(int libraryId, bool forceScan = false) + // { + // var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, + // LibraryIncludes.Folders | LibraryIncludes.ExcludePatterns | LibraryIncludes.FileTypes); + // + // if (library == null) + // { + // return; + // } + // + // // Create a ScannerOption + // var options = new ScannerOption() + // { + // FileTypePattern = library.LibraryFileTypes.Select(s => s.FileTypeGroup).ToList(), + // ForceScan = forceScan, + // ExcludePatterns = [.. library.LibraryExcludePatterns.Select(s => s.Pattern)], + // FolderPaths = [.. library.Folders.Select(f => Parser.Parser.NormalizePath(f.Path))] + // }; + // + // + // // Find all the information about the directories and their files + // var files = ScanFiles(options); + // + // // Parse said information + // + // + // return; + // } public List ScanFiles(ScannerOption options) { @@ -120,6 +120,7 @@ public class FileScanner : IFileScanner // Add the directory and its files to the result scannedDirectories.Add(new ScannedDirectory { + FolderRoot = folderPath, DirectoryPath = directory, LastModifiedUtc = directoryLastModifiedUtc, Files = files From 7ce36bfc440caf76c11bbdcabd182a9635464c5a Mon Sep 17 00:00:00 2001 From: Fesaa <77553571+Fesaa@users.noreply.github.com> Date: Sat, 10 May 2025 00:18:13 +0200 Subject: [PATCH 11/57] People Aliases and Merging (#3795) Co-authored-by: Joseph Milazzo --- API.Tests/Helpers/PersonHelperTests.cs | 335 +- .../Services/ExternalMetadataServiceTests.cs | 124 + API.Tests/Services/PersonServiceTests.cs | 286 ++ API.Tests/Services/SeriesServiceTests.cs | 1 + API/Controllers/MetadataController.cs | 3 +- API/Controllers/OPDSController.cs | 1 + API/Controllers/PersonController.cs | 61 +- API/Controllers/ReadingListController.cs | 2 +- API/Controllers/SearchController.cs | 1 + API/DTOs/ChapterDto.cs | 1 + API/DTOs/Metadata/ChapterMetadataDto.cs | 1 + API/DTOs/Person/BrowsePersonDto.cs | 4 +- API/DTOs/Person/PersonDto.cs | 5 +- API/DTOs/Person/PersonMergeDto.cs | 17 + API/DTOs/Person/UpdatePersonDto.cs | 4 +- API/DTOs/ReadingLists/ReadingListCast.cs | 1 + API/DTOs/Search/SearchResultGroupDto.cs | 1 + API/DTOs/SeriesMetadataDto.cs | 1 + API/DTOs/UpdateChapterDto.cs | 1 + API/Data/DataContext.cs | 1 + .../20250507221026_PersonAliases.Designer.cs | 3571 +++++++++++++++++ .../20250507221026_PersonAliases.cs | 47 + .../Migrations/DataContextModelSnapshot.cs | 35 + API/Data/Repositories/PersonRepository.cs | 105 +- .../Repositories/ReadingListRepository.cs | 2 +- API/Data/Repositories/SeriesRepository.cs | 18 +- API/Entities/Person/Person.cs | 7 +- API/Entities/Person/PersonAlias.cs | 11 + .../ApplicationServiceExtensions.cs | 1 + .../Filtering/SearchQueryableExtensions.cs | 22 +- .../QueryExtensions/IncludesExtensions.cs | 23 +- API/Helpers/AutoMapperProfiles.cs | 4 +- API/Helpers/Builders/PersonAliasBuilder.cs | 19 + API/Helpers/Builders/PersonBuilder.cs | 18 +- API/Helpers/PersonHelper.cs | 35 +- API/I18N/en.json | 1 + API/Services/PersonService.cs | 147 + API/Services/Plus/ExternalMetadataService.cs | 52 +- API/Services/SeriesService.cs | 4 +- API/Services/Tasks/Metadata/CoverDbService.cs | 2 +- API/SignalR/MessageFactory.cs | 18 + UI/Web/src/_series-detail-common.scss | 2 +- UI/Web/src/app/_models/library/library.ts | 3 + UI/Web/src/app/_models/metadata/person.ts | 1 + .../app/_services/action-factory.service.ts | 14 +- .../src/app/_services/message-hub.service.ts | 13 +- UI/Web/src/app/_services/person.service.ts | 21 +- .../edit-chapter-modal.component.ts | 2 +- .../edit-series-modal.component.ts | 2 +- .../nav-header/nav-header.component.html | 11 +- .../nav-header/nav-header.component.scss | 4 + .../edit-person-modal.component.html | 13 + .../edit-person-modal.component.ts | 35 +- .../merge-person-modal.component.html | 65 + .../merge-person-modal.component.scss | 0 .../merge-person-modal.component.ts | 101 + .../person-detail.component.html | 42 +- .../person-detail/person-detail.component.ts | 152 +- .../series-detail.component.html | 2 +- .../series-detail.component.scss | 4 + .../badge-expander.component.scss | 9 +- .../shared/edit-list/edit-list.component.html | 26 +- .../shared/edit-list/edit-list.component.ts | 12 +- .../library-settings-modal.component.ts | 3 +- .../_components/typeahead.component.ts | 11 + UI/Web/src/assets/langs/en.json | 28 +- UI/Web/src/theme/themes/dark.scss | 3 + 67 files changed, 5288 insertions(+), 284 deletions(-) create mode 100644 API.Tests/Services/PersonServiceTests.cs create mode 100644 API/DTOs/Person/PersonMergeDto.cs create mode 100644 API/Data/Migrations/20250507221026_PersonAliases.Designer.cs create mode 100644 API/Data/Migrations/20250507221026_PersonAliases.cs create mode 100644 API/Entities/Person/PersonAlias.cs create mode 100644 API/Helpers/Builders/PersonAliasBuilder.cs create mode 100644 API/Services/PersonService.cs create mode 100644 UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.html create mode 100644 UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.scss create mode 100644 UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.ts diff --git a/API.Tests/Helpers/PersonHelperTests.cs b/API.Tests/Helpers/PersonHelperTests.cs index 66713e17c..47dab48da 100644 --- a/API.Tests/Helpers/PersonHelperTests.cs +++ b/API.Tests/Helpers/PersonHelperTests.cs @@ -1,5 +1,10 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; +using API.Entities.Enums; +using API.Helpers; +using API.Helpers.Builders; +using Xunit; namespace API.Tests.Helpers; @@ -7,127 +12,215 @@ public class PersonHelperTests : AbstractDbTest { protected override async Task ResetDb() { + Context.Series.RemoveRange(Context.Series.ToList()); + Context.Person.RemoveRange(Context.Person.ToList()); + Context.Library.RemoveRange(Context.Library.ToList()); Context.Series.RemoveRange(Context.Series.ToList()); await Context.SaveChangesAsync(); } - // - // // 1. Test adding new people and keeping existing ones - // [Fact] - // public async Task UpdateChapterPeopleAsync_AddNewPeople_ExistingPersonRetained() - // { - // var existingPerson = new PersonBuilder("Joe Shmo").Build(); - // var chapter = new ChapterBuilder("1").Build(); - // - // // Create an existing person and assign them to the series with a role - // var series = new SeriesBuilder("Test 1") - // .WithFormat(MangaFormat.Archive) - // .WithMetadata(new SeriesMetadataBuilder() - // .WithPerson(existingPerson, PersonRole.Editor) - // .Build()) - // .WithVolume(new VolumeBuilder("1").WithChapter(chapter).Build()) - // .Build(); - // - // _unitOfWork.SeriesRepository.Add(series); - // await _unitOfWork.CommitAsync(); - // - // // Call UpdateChapterPeopleAsync with one existing and one new person - // await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo", "New Person" }, PersonRole.Editor, _unitOfWork); - // - // // Assert existing person retained and new person added - // var people = await _unitOfWork.PersonRepository.GetAllPeople(); - // Assert.Contains(people, p => p.Name == "Joe Shmo"); - // Assert.Contains(people, p => p.Name == "New Person"); - // - // var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); - // Assert.Contains("Joe Shmo", chapterPeople); - // Assert.Contains("New Person", chapterPeople); - // } - // - // // 2. Test removing a person no longer in the list - // [Fact] - // public async Task UpdateChapterPeopleAsync_RemovePeople() - // { - // var existingPerson1 = new PersonBuilder("Joe Shmo").Build(); - // var existingPerson2 = new PersonBuilder("Jane Doe").Build(); - // var chapter = new ChapterBuilder("1").Build(); - // - // var series = new SeriesBuilder("Test 1") - // .WithVolume(new VolumeBuilder("1") - // .WithChapter(new ChapterBuilder("1") - // .WithPerson(existingPerson1, PersonRole.Editor) - // .WithPerson(existingPerson2, PersonRole.Editor) - // .Build()) - // .Build()) - // .Build(); - // - // _unitOfWork.SeriesRepository.Add(series); - // await _unitOfWork.CommitAsync(); - // - // // Call UpdateChapterPeopleAsync with only one person - // await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, _unitOfWork); - // - // var people = await _unitOfWork.PersonRepository.GetAllPeople(); - // Assert.DoesNotContain(people, p => p.Name == "Jane Doe"); - // - // var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); - // Assert.Contains("Joe Shmo", chapterPeople); - // Assert.DoesNotContain("Jane Doe", chapterPeople); - // } - // - // // 3. Test no changes when the list of people is the same - // [Fact] - // public async Task UpdateChapterPeopleAsync_NoChanges() - // { - // var existingPerson = new PersonBuilder("Joe Shmo").Build(); - // var chapter = new ChapterBuilder("1").Build(); - // - // var series = new SeriesBuilder("Test 1") - // .WithVolume(new VolumeBuilder("1") - // .WithChapter(new ChapterBuilder("1") - // .WithPerson(existingPerson, PersonRole.Editor) - // .Build()) - // .Build()) - // .Build(); - // - // _unitOfWork.SeriesRepository.Add(series); - // await _unitOfWork.CommitAsync(); - // - // // Call UpdateChapterPeopleAsync with the same list - // await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, _unitOfWork); - // - // var people = await _unitOfWork.PersonRepository.GetAllPeople(); - // Assert.Contains(people, p => p.Name == "Joe Shmo"); - // - // var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); - // Assert.Contains("Joe Shmo", chapterPeople); - // Assert.Single(chapter.People); // No duplicate entries - // } - // - // // 4. Test multiple roles for a person - // [Fact] - // public async Task UpdateChapterPeopleAsync_MultipleRoles() - // { - // var person = new PersonBuilder("Joe Shmo").Build(); - // var chapter = new ChapterBuilder("1").Build(); - // - // var series = new SeriesBuilder("Test 1") - // .WithVolume(new VolumeBuilder("1") - // .WithChapter(new ChapterBuilder("1") - // .WithPerson(person, PersonRole.Writer) // Assign person as Writer - // .Build()) - // .Build()) - // .Build(); - // - // _unitOfWork.SeriesRepository.Add(series); - // await _unitOfWork.CommitAsync(); - // - // // Add same person as Editor - // await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, _unitOfWork); - // - // // Ensure that the same person is assigned with two roles - // var chapterPeople = chapter.People.Where(cp => cp.Person.Name == "Joe Shmo").ToList(); - // Assert.Equal(2, chapterPeople.Count); // One for each role - // Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Writer); - // Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Editor); - // } + + // 1. Test adding new people and keeping existing ones + [Fact] + public async Task UpdateChapterPeopleAsync_AddNewPeople_ExistingPersonRetained() + { + await ResetDb(); + + var library = new LibraryBuilder("My Library") + .Build(); + + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var existingPerson = new PersonBuilder("Joe Shmo").Build(); + var chapter = new ChapterBuilder("1").Build(); + + // Create an existing person and assign them to the series with a role + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(existingPerson, PersonRole.Editor) + .Build()) + .WithVolume(new VolumeBuilder("1").WithChapter(chapter).Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + await UnitOfWork.CommitAsync(); + + // Call UpdateChapterPeopleAsync with one existing and one new person + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo", "New Person" }, PersonRole.Editor, UnitOfWork); + + // Assert existing person retained and new person added + var people = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Contains(people, p => p.Name == "Joe Shmo"); + Assert.Contains(people, p => p.Name == "New Person"); + + var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); + Assert.Contains("Joe Shmo", chapterPeople); + Assert.Contains("New Person", chapterPeople); + } + + // 2. Test removing a person no longer in the list + [Fact] + public async Task UpdateChapterPeopleAsync_RemovePeople() + { + await ResetDb(); + + var library = new LibraryBuilder("My Library") + .Build(); + + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var existingPerson1 = new PersonBuilder("Joe Shmo").Build(); + var existingPerson2 = new PersonBuilder("Jane Doe").Build(); + var chapter = new ChapterBuilder("1") + .WithPerson(existingPerson1, PersonRole.Editor) + .WithPerson(existingPerson2, PersonRole.Editor) + .Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + await UnitOfWork.CommitAsync(); + + // Call UpdateChapterPeopleAsync with only one person + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, UnitOfWork); + + // PersonHelper does not remove the Person from the global DbSet itself + await UnitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated(); + + var people = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.DoesNotContain(people, p => p.Name == "Jane Doe"); + + var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); + Assert.Contains("Joe Shmo", chapterPeople); + Assert.DoesNotContain("Jane Doe", chapterPeople); + } + + // 3. Test no changes when the list of people is the same + [Fact] + public async Task UpdateChapterPeopleAsync_NoChanges() + { + await ResetDb(); + + var library = new LibraryBuilder("My Library") + .Build(); + + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var existingPerson = new PersonBuilder("Joe Shmo").Build(); + var chapter = new ChapterBuilder("1").WithPerson(existingPerson, PersonRole.Editor).Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + await UnitOfWork.CommitAsync(); + + // Call UpdateChapterPeopleAsync with the same list + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, UnitOfWork); + + var people = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Contains(people, p => p.Name == "Joe Shmo"); + + var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); + Assert.Contains("Joe Shmo", chapterPeople); + Assert.Single(chapter.People); // No duplicate entries + } + + // 4. Test multiple roles for a person + [Fact] + public async Task UpdateChapterPeopleAsync_MultipleRoles() + { + await ResetDb(); + + var library = new LibraryBuilder("My Library") + .Build(); + + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var person = new PersonBuilder("Joe Shmo").Build(); + var chapter = new ChapterBuilder("1").WithPerson(person, PersonRole.Writer).Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + await UnitOfWork.CommitAsync(); + + // Add same person as Editor + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, UnitOfWork); + + // Ensure that the same person is assigned with two roles + var chapterPeople = chapter + .People + .Where(cp => + cp.Person.Name == "Joe Shmo") + .ToList(); + Assert.Equal(2, chapterPeople.Count); // One for each role + Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Writer); + Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Editor); + } + + [Fact] + public async Task UpdateChapterPeopleAsync_MatchOnAlias_NoChanges() + { + await ResetDb(); + + var library = new LibraryBuilder("My Library") + .Build(); + + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var person = new PersonBuilder("Joe Doe") + .WithAlias("Jonny Doe") + .Build(); + + var chapter = new ChapterBuilder("1") + .WithPerson(person, PersonRole.Editor) + .Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + await UnitOfWork.CommitAsync(); + + // Add on Name + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Doe" }, PersonRole.Editor, UnitOfWork); + await UnitOfWork.CommitAsync(); + + var allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + + // Add on alias + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Jonny Doe" }, PersonRole.Editor, UnitOfWork); + await UnitOfWork.CommitAsync(); + + allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + } + + // TODO: Unit tests for series } diff --git a/API.Tests/Services/ExternalMetadataServiceTests.cs b/API.Tests/Services/ExternalMetadataServiceTests.cs index c2c226538..8310ed269 100644 --- a/API.Tests/Services/ExternalMetadataServiceTests.cs +++ b/API.Tests/Services/ExternalMetadataServiceTests.cs @@ -1678,6 +1678,130 @@ public class ExternalMetadataServiceTests : AbstractDbTest #endregion + #region People Alias + + [Fact] + public async Task PeopleAliasing_AddAsAlias() + { + await ResetDb(); + + const string seriesName = "Test - People - Add as Alias"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + Context.Person.Add(new PersonBuilder("John Doe").Build()); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("Doe", "John", "Story")] + }, 1); + + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + + var allWriters = postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).ToList(); + Assert.Single(allWriters); + + var johnDoe = allWriters[0].Person; + + Assert.Contains("Doe John", johnDoe.Aliases.Select(pa => pa.Alias)); + } + + [Fact] + public async Task PeopleAliasing_AddOnAlias() + { + await ResetDb(); + + const string seriesName = "Test - People - Add as Alias"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + + Context.Person.Add(new PersonBuilder("John Doe").WithAlias("Doe John").Build()); + + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("Doe", "John", "Story")] + }, 1); + + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + + var allWriters = postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).ToList(); + Assert.Single(allWriters); + + var johnDoe = allWriters[0].Person; + + Assert.Contains("Doe John", johnDoe.Aliases.Select(pa => pa.Alias)); + } + + [Fact] + public async Task PeopleAliasing_DontAddAsAlias_SameButNotSwitched() + { + await ResetDb(); + + const string seriesName = "Test - People - Add as Alias"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe Doe", "Story"), CreateStaff("Doe", "John Doe", "Story")] + }, 1); + + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + + var allWriters = postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).ToList(); + Assert.Equal(2, allWriters.Count); + } + + #endregion + #region People - Characters [Fact] diff --git a/API.Tests/Services/PersonServiceTests.cs b/API.Tests/Services/PersonServiceTests.cs new file mode 100644 index 000000000..5c1929b1c --- /dev/null +++ b/API.Tests/Services/PersonServiceTests.cs @@ -0,0 +1,286 @@ +using System.Linq; +using System.Threading.Tasks; +using API.Data.Repositories; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Person; +using API.Extensions; +using API.Helpers.Builders; +using API.Services; +using Xunit; + +namespace API.Tests.Services; + +public class PersonServiceTests: AbstractDbTest +{ + + [Fact] + public async Task PersonMerge_KeepNonEmptyMetadata() + { + var ps = new PersonService(UnitOfWork); + + var person1 = new Person + { + Name = "Casey Delores", + NormalizedName = "Casey Delores".ToNormalized(), + HardcoverId = "ANonEmptyId", + MalId = 12, + }; + + var person2 = new Person + { + Name= "Delores Casey", + NormalizedName = "Delores Casey".ToNormalized(), + Description = "Hi, I'm Delores Casey!", + Aliases = [new PersonAliasBuilder("Casey, Delores").Build()], + AniListId = 27, + }; + + UnitOfWork.PersonRepository.Attach(person1); + UnitOfWork.PersonRepository.Attach(person2); + await UnitOfWork.CommitAsync(); + + await ps.MergePeopleAsync(person2, person1); + + var allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + + var person = allPeople[0]; + Assert.Equal("Casey Delores", person.Name); + Assert.NotEmpty(person.Description); + Assert.Equal(27, person.AniListId); + Assert.NotNull(person.HardcoverId); + Assert.NotEmpty(person.HardcoverId); + Assert.Contains(person.Aliases, pa => pa.Alias == "Delores Casey"); + Assert.Contains(person.Aliases, pa => pa.Alias == "Casey, Delores"); + } + + [Fact] + public async Task PersonMerge_MergedPersonDestruction() + { + var ps = new PersonService(UnitOfWork); + + var person1 = new Person + { + Name = "Casey Delores", + NormalizedName = "Casey Delores".ToNormalized(), + }; + + var person2 = new Person + { + Name = "Delores Casey", + NormalizedName = "Delores Casey".ToNormalized(), + }; + + UnitOfWork.PersonRepository.Attach(person1); + UnitOfWork.PersonRepository.Attach(person2); + await UnitOfWork.CommitAsync(); + + await ps.MergePeopleAsync(person2, person1); + var allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + } + + [Fact] + public async Task PersonMerge_RetentionChapters() + { + var ps = new PersonService(UnitOfWork); + + var library = new LibraryBuilder("My Library").Build(); + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var user = new AppUserBuilder("Amelia", "amelia@localhost") + .WithLibrary(library).Build(); + UnitOfWork.UserRepository.Add(user); + + var person = new PersonBuilder("Jillian Cowan").Build(); + + var person2 = new PersonBuilder("Cowan Jillian").Build(); + + var chapter = new ChapterBuilder("1") + .WithPerson(person, PersonRole.Editor) + .Build(); + + var chapter2 = new ChapterBuilder("2") + .WithPerson(person2, PersonRole.Editor) + .Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + var series2 = new SeriesBuilder("Test 2") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("2") + .WithChapter(chapter2) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + UnitOfWork.SeriesRepository.Add(series2); + await UnitOfWork.CommitAsync(); + + await ps.MergePeopleAsync(person2, person); + + var allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + var mergedPerson = allPeople[0]; + + Assert.Equal("Jillian Cowan", mergedPerson.Name); + + var chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(1, 1, PersonRole.Editor); + Assert.Equal(2, chapters.Count()); + + chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(1, ChapterIncludes.People); + Assert.NotNull(chapter); + Assert.Single(chapter.People); + + chapter2 = await UnitOfWork.ChapterRepository.GetChapterAsync(2, ChapterIncludes.People); + Assert.NotNull(chapter2); + Assert.Single(chapter2.People); + + Assert.Equal(chapter.People.First().PersonId, chapter2.People.First().PersonId); + } + + [Fact] + public async Task PersonMerge_NoDuplicateChaptersOrSeries() + { + await ResetDb(); + + var ps = new PersonService(UnitOfWork); + + var library = new LibraryBuilder("My Library").Build(); + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var user = new AppUserBuilder("Amelia", "amelia@localhost") + .WithLibrary(library).Build(); + UnitOfWork.UserRepository.Add(user); + + var person = new PersonBuilder("Jillian Cowan").Build(); + + var person2 = new PersonBuilder("Cowan Jillian").Build(); + + var chapter = new ChapterBuilder("1") + .WithPerson(person, PersonRole.Editor) + .WithPerson(person2, PersonRole.Colorist) + .Build(); + + var chapter2 = new ChapterBuilder("2") + .WithPerson(person2, PersonRole.Editor) + .WithPerson(person, PersonRole.Editor) + .Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(person, PersonRole.Editor) + .WithPerson(person2, PersonRole.Editor) + .Build()) + .Build(); + + var series2 = new SeriesBuilder("Test 2") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("2") + .WithChapter(chapter2) + .Build()) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(person, PersonRole.Editor) + .WithPerson(person2, PersonRole.Colorist) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + UnitOfWork.SeriesRepository.Add(series2); + await UnitOfWork.CommitAsync(); + + await ps.MergePeopleAsync(person2, person); + var allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + + var mergedPerson = await UnitOfWork.PersonRepository.GetPersonById(person.Id, PersonIncludes.All); + Assert.NotNull(mergedPerson); + Assert.Equal(3, mergedPerson.ChapterPeople.Count); + Assert.Equal(3, mergedPerson.SeriesMetadataPeople.Count); + + chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(chapter.Id, ChapterIncludes.People); + Assert.NotNull(chapter); + Assert.Equal(2, chapter.People.Count); + Assert.Single(chapter.People.Select(p => p.Person.Id).Distinct()); + Assert.Contains(chapter.People, p => p.Role == PersonRole.Editor); + Assert.Contains(chapter.People, p => p.Role == PersonRole.Colorist); + + chapter2 = await UnitOfWork.ChapterRepository.GetChapterAsync(chapter2.Id, ChapterIncludes.People); + Assert.NotNull(chapter2); + Assert.Single(chapter2.People); + Assert.Contains(chapter2.People, p => p.Role == PersonRole.Editor); + Assert.DoesNotContain(chapter2.People, p => p.Role == PersonRole.Colorist); + + series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id, SeriesIncludes.Metadata); + Assert.NotNull(series); + Assert.Single(series.Metadata.People); + Assert.Contains(series.Metadata.People, p => p.Role == PersonRole.Editor); + Assert.DoesNotContain(series.Metadata.People, p => p.Role == PersonRole.Colorist); + + series2 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(series2.Id, SeriesIncludes.Metadata); + Assert.NotNull(series2); + Assert.Equal(2, series2.Metadata.People.Count); + Assert.Contains(series2.Metadata.People, p => p.Role == PersonRole.Editor); + Assert.Contains(series2.Metadata.People, p => p.Role == PersonRole.Colorist); + + + } + + [Fact] + public async Task PersonAddAlias_NoOverlap() + { + await ResetDb(); + + UnitOfWork.PersonRepository.Attach(new PersonBuilder("Jillian Cowan").Build()); + UnitOfWork.PersonRepository.Attach(new PersonBuilder("Jilly Cowan").WithAlias("Jolly Cowan").Build()); + await UnitOfWork.CommitAsync(); + + var ps = new PersonService(UnitOfWork); + + var person1 = await UnitOfWork.PersonRepository.GetPersonByNameOrAliasAsync("Jillian Cowan"); + var person2 = await UnitOfWork.PersonRepository.GetPersonByNameOrAliasAsync("Jilly Cowan"); + Assert.NotNull(person1); + Assert.NotNull(person2); + + // Overlap on Name + var success = await ps.UpdatePersonAliasesAsync(person1, ["Jilly Cowan"]); + Assert.False(success); + + // Overlap on alias + success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan"]); + Assert.False(success); + + // No overlap + success = await ps.UpdatePersonAliasesAsync(person2, ["Jilly Joy Cowan"]); + Assert.True(success); + + // Some overlap + success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan", "Jilly Joy Cowan"]); + Assert.False(success); + + // Some overlap + success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan", "Jilly Joy Cowan"]); + Assert.False(success); + + Assert.Single(person2.Aliases); + } + + protected override async Task ResetDb() + { + Context.Person.RemoveRange(Context.Person.ToList()); + + await Context.SaveChangesAsync(); + } +} diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index 4bf0e6782..55babf815 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -8,6 +8,7 @@ using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.Metadata; +using API.DTOs.Person; using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index b08ac1f38..10a5f393a 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -6,9 +6,9 @@ using System.Threading.Tasks; using API.Constants; using API.Data; using API.Data.Repositories; -using API.DTOs; using API.DTOs.Filtering; using API.DTOs.Metadata; +using API.DTOs.Person; using API.DTOs.Recommendation; using API.DTOs.SeriesDetail; using API.Entities.Enums; @@ -74,6 +74,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc { return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId(), ids)); } + return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId())); } diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index fcc4ca58f..6e96c3063 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -15,6 +15,7 @@ using API.DTOs.CollectionTags; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; using API.DTOs.OPDS; +using API.DTOs.Person; using API.DTOs.Progress; using API.DTOs.Search; using API.Entities; diff --git a/API/Controllers/PersonController.cs b/API/Controllers/PersonController.cs index 1094a1137..a2ab3bf88 100644 --- a/API/Controllers/PersonController.cs +++ b/API/Controllers/PersonController.cs @@ -1,7 +1,10 @@ using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using API.Data; +using API.Data.Repositories; using API.DTOs; +using API.DTOs.Person; using API.Entities.Enums; using API.Extensions; using API.Helpers; @@ -24,9 +27,10 @@ public class PersonController : BaseApiController private readonly ICoverDbService _coverDbService; private readonly IImageService _imageService; private readonly IEventHub _eventHub; + private readonly IPersonService _personService; public PersonController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper, - ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub) + ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub, IPersonService personService) { _unitOfWork = unitOfWork; _localizationService = localizationService; @@ -34,6 +38,7 @@ public class PersonController : BaseApiController _coverDbService = coverDbService; _imageService = imageService; _eventHub = eventHub; + _personService = personService; } @@ -43,6 +48,17 @@ public class PersonController : BaseApiController return Ok(await _unitOfWork.PersonRepository.GetPersonDtoByName(name, User.GetUserId())); } + /// + /// Find a person by name or alias against a query string + /// + /// + /// + [HttpGet("search")] + public async Task>> SearchPeople([FromQuery] string queryString) + { + return Ok(await _unitOfWork.PersonRepository.SearchPeople(queryString)); + } + /// /// Returns all roles for a Person /// @@ -54,6 +70,7 @@ public class PersonController : BaseApiController return Ok(await _unitOfWork.PersonRepository.GetRolesForPersonByName(personId, User.GetUserId())); } + /// /// Returns a list of authors and artists for browsing /// @@ -78,7 +95,7 @@ public class PersonController : BaseApiController public async Task> UpdatePerson(UpdatePersonDto dto) { // This needs to get all people and update them equally - var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id); + var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id, PersonIncludes.Aliases); if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist")); if (string.IsNullOrEmpty(dto.Name)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-required")); @@ -90,6 +107,10 @@ public class PersonController : BaseApiController return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-unique")); } + var success = await _personService.UpdatePersonAliasesAsync(person, dto.Aliases); + if (!success) return BadRequest(await _localizationService.Translate(User.GetUserId(), "aliases-have-overlap")); + + person.Name = dto.Name?.Trim(); person.Description = dto.Description ?? string.Empty; person.CoverImageLocked = dto.CoverImageLocked; @@ -173,5 +194,41 @@ public class PersonController : BaseApiController return Ok(await _unitOfWork.PersonRepository.GetChaptersForPersonByRole(personId, User.GetUserId(), role)); } + /// + /// Merges Persons into one, this action is irreversible + /// + /// + /// + [HttpPost("merge")] + public async Task> MergePeople(PersonMergeDto dto) + { + var dst = await _unitOfWork.PersonRepository.GetPersonById(dto.DestId, PersonIncludes.All); + if (dst == null) return BadRequest(); + + var src = await _unitOfWork.PersonRepository.GetPersonById(dto.SrcId, PersonIncludes.All); + if (src == null) return BadRequest(); + + await _personService.MergePeopleAsync(src, dst); + await _eventHub.SendMessageAsync(MessageFactory.PersonMerged, MessageFactory.PersonMergedMessage(dst, src)); + + return Ok(_mapper.Map(dst)); + } + + /// + /// Ensure the alias is valid to be added. For example, the alias cannot be on another person or be the same as the current person name/alias. + /// + /// + /// + /// + [HttpGet("valid-alias")] + public async Task> IsValidAlias(int personId, string alias) + { + var person = await _unitOfWork.PersonRepository.GetPersonById(personId, PersonIncludes.Aliases); + if (person == null) return NotFound(); + + var existingAlias = await _unitOfWork.PersonRepository.AnyAliasExist(alias); + return Ok(!existingAlias && person.NormalizedName != alias.ToNormalized()); + } + } diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 6c9be6c75..1187992bc 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -4,7 +4,7 @@ using System.Threading.Tasks; using API.Constants; using API.Data; using API.Data.Repositories; -using API.DTOs; +using API.DTOs.Person; using API.DTOs.ReadingLists; using API.Entities.Enums; using API.Extensions; diff --git a/API/Controllers/SearchController.cs b/API/Controllers/SearchController.cs index 5aa54d1db..cc89a124e 100644 --- a/API/Controllers/SearchController.cs +++ b/API/Controllers/SearchController.cs @@ -63,6 +63,7 @@ public class SearchController : BaseApiController var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (user == null) return Unauthorized(); + var libraries = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search).ToList(); if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted")); diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index 70fb12e85..85624b51c 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using API.DTOs.Metadata; +using API.DTOs.Person; using API.Entities.Enums; using API.Entities.Interfaces; diff --git a/API/DTOs/Metadata/ChapterMetadataDto.cs b/API/DTOs/Metadata/ChapterMetadataDto.cs index 1adc52cd1..c79436e24 100644 --- a/API/DTOs/Metadata/ChapterMetadataDto.cs +++ b/API/DTOs/Metadata/ChapterMetadataDto.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using API.DTOs.Person; using API.Entities.Enums; namespace API.DTOs.Metadata; diff --git a/API/DTOs/Person/BrowsePersonDto.cs b/API/DTOs/Person/BrowsePersonDto.cs index 8d6999973..c7d318e79 100644 --- a/API/DTOs/Person/BrowsePersonDto.cs +++ b/API/DTOs/Person/BrowsePersonDto.cs @@ -1,4 +1,6 @@ -namespace API.DTOs; +using API.DTOs.Person; + +namespace API.DTOs; /// /// Used to browse writers and click in to see their series diff --git a/API/DTOs/Person/PersonDto.cs b/API/DTOs/Person/PersonDto.cs index 511317f2a..db152e3b1 100644 --- a/API/DTOs/Person/PersonDto.cs +++ b/API/DTOs/Person/PersonDto.cs @@ -1,6 +1,6 @@ -using System.Runtime.Serialization; +using System.Collections.Generic; -namespace API.DTOs; +namespace API.DTOs.Person; #nullable enable public class PersonDto @@ -13,6 +13,7 @@ public class PersonDto public string? SecondaryColor { get; set; } public string? CoverImage { get; set; } + public List Aliases { get; set; } = []; public string? Description { get; set; } /// diff --git a/API/DTOs/Person/PersonMergeDto.cs b/API/DTOs/Person/PersonMergeDto.cs new file mode 100644 index 000000000..b5dc23375 --- /dev/null +++ b/API/DTOs/Person/PersonMergeDto.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs; + +public sealed record PersonMergeDto +{ + /// + /// The id of the person being merged into + /// + [Required] + public int DestId { get; init; } + /// + /// The id of the person being merged. This person will be removed, and become an alias of + /// + [Required] + public int SrcId { get; init; } +} diff --git a/API/DTOs/Person/UpdatePersonDto.cs b/API/DTOs/Person/UpdatePersonDto.cs index 29190151f..b43a45e88 100644 --- a/API/DTOs/Person/UpdatePersonDto.cs +++ b/API/DTOs/Person/UpdatePersonDto.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; namespace API.DTOs; #nullable enable @@ -11,6 +12,7 @@ public sealed record UpdatePersonDto public bool CoverImageLocked { get; set; } [Required] public string Name {get; set;} + public IList Aliases { get; set; } = []; public string? Description { get; set; } public int? AniListId { get; set; } diff --git a/API/DTOs/ReadingLists/ReadingListCast.cs b/API/DTOs/ReadingLists/ReadingListCast.cs index 8f2587426..855bb12b7 100644 --- a/API/DTOs/ReadingLists/ReadingListCast.cs +++ b/API/DTOs/ReadingLists/ReadingListCast.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using API.DTOs.Person; namespace API.DTOs.ReadingLists; diff --git a/API/DTOs/Search/SearchResultGroupDto.cs b/API/DTOs/Search/SearchResultGroupDto.cs index 20a53f853..11c4bdc08 100644 --- a/API/DTOs/Search/SearchResultGroupDto.cs +++ b/API/DTOs/Search/SearchResultGroupDto.cs @@ -2,6 +2,7 @@ using API.DTOs.Collection; using API.DTOs.CollectionTags; using API.DTOs.Metadata; +using API.DTOs.Person; using API.DTOs.Reader; using API.DTOs.ReadingLists; diff --git a/API/DTOs/SeriesMetadataDto.cs b/API/DTOs/SeriesMetadataDto.cs index 701034d80..fa745148e 100644 --- a/API/DTOs/SeriesMetadataDto.cs +++ b/API/DTOs/SeriesMetadataDto.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using API.DTOs.Metadata; +using API.DTOs.Person; using API.Entities.Enums; namespace API.DTOs; diff --git a/API/DTOs/UpdateChapterDto.cs b/API/DTOs/UpdateChapterDto.cs index ec2f1cf62..9ead8adc8 100644 --- a/API/DTOs/UpdateChapterDto.cs +++ b/API/DTOs/UpdateChapterDto.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using API.DTOs.Metadata; +using API.DTOs.Person; using API.Entities.Enums; namespace API.DTOs; diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 714e29fdf..ce35ba7ec 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -49,6 +49,7 @@ public sealed class DataContext : IdentityDbContext ReadingList { get; set; } = null!; public DbSet ReadingListItem { get; set; } = null!; public DbSet Person { get; set; } = null!; + public DbSet PersonAlias { get; set; } = null!; public DbSet Genre { get; set; } = null!; public DbSet Tag { get; set; } = null!; public DbSet SiteTheme { get; set; } = null!; diff --git a/API/Data/Migrations/20250507221026_PersonAliases.Designer.cs b/API/Data/Migrations/20250507221026_PersonAliases.Designer.cs new file mode 100644 index 000000000..5d76571e1 --- /dev/null +++ b/API/Data/Migrations/20250507221026_PersonAliases.Designer.cs @@ -0,0 +1,3571 @@ +// +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("20250507221026_PersonAliases")] + partial class PersonAliases + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + 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("HasRunScrobbleEventGeneration") + .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("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.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.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.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("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("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("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.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("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.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.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.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.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.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.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.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("ChapterRatings"); + + 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("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.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/20250507221026_PersonAliases.cs b/API/Data/Migrations/20250507221026_PersonAliases.cs new file mode 100644 index 000000000..cb046a131 --- /dev/null +++ b/API/Data/Migrations/20250507221026_PersonAliases.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class PersonAliases : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PersonAlias", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Alias = table.Column(type: "TEXT", nullable: true), + NormalizedAlias = table.Column(type: "TEXT", nullable: true), + PersonId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PersonAlias", x => x.Id); + table.ForeignKey( + name: "FK_PersonAlias_Person_PersonId", + column: x => x.PersonId, + principalTable: "Person", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PersonAlias_PersonId", + table: "PersonAlias", + column: "PersonId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PersonAlias"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index a66568dcc..bdeb3d7c4 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -1836,6 +1836,28 @@ namespace API.Data.Migrations b.ToTable("Person"); }); + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => { b.Property("SeriesMetadataId") @@ -3082,6 +3104,17 @@ namespace API.Data.Migrations b.Navigation("Person"); }); + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => { b.HasOne("API.Entities.Person.Person", "Person") @@ -3496,6 +3529,8 @@ namespace API.Data.Migrations modelBuilder.Entity("API.Entities.Person.Person", b => { + b.Navigation("Aliases"); + b.Navigation("ChapterPeople"); b.Navigation("SeriesMetadataPeople"); diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index db66ecd79..dce3f86ef 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -1,7 +1,9 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.DTOs; +using API.DTOs.Person; using API.Entities.Enums; using API.Entities.Person; using API.Extensions; @@ -14,6 +16,17 @@ using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; #nullable enable +[Flags] +public enum PersonIncludes +{ + None = 1 << 0, + Aliases = 1 << 1, + ChapterPeople = 1 << 2, + SeriesPeople = 1 << 3, + + All = Aliases | ChapterPeople | SeriesPeople, +} + public interface IPersonRepository { void Attach(Person person); @@ -23,24 +36,41 @@ public interface IPersonRepository void Remove(SeriesMetadataPeople person); void Update(Person person); - Task> GetAllPeople(); - Task> GetAllPersonDtosAsync(int userId); - Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role); + Task> GetAllPeople(PersonIncludes includes = PersonIncludes.Aliases); + Task> GetAllPersonDtosAsync(int userId, PersonIncludes includes = PersonIncludes.None); + Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role, PersonIncludes includes = PersonIncludes.None); Task RemoveAllPeopleNoLongerAssociated(); - Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null); + Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null, PersonIncludes includes = PersonIncludes.None); 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 GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None); + Task GetPersonDtoByName(string name, int userId, PersonIncludes includes = PersonIncludes.Aliases); + /// + /// Returns a person matched on normalized name or alias + /// + /// + /// + /// + Task GetPersonByNameOrAliasAsync(string name, PersonIncludes includes = PersonIncludes.Aliases); Task IsNameUnique(string name); Task> GetSeriesKnownFor(int personId); Task> GetChaptersForPersonByRole(int personId, int userId, PersonRole role); - Task> GetPeopleByNames(List normalizedNames); - Task GetPersonByAniListId(int aniListId); + /// + /// Returns all people with a matching name, or alias + /// + /// + /// + /// + Task> GetPeopleByNames(List normalizedNames, PersonIncludes includes = PersonIncludes.Aliases); + Task GetPersonByAniListId(int aniListId, PersonIncludes includes = PersonIncludes.Aliases); + + Task> SearchPeople(string searchQuery, PersonIncludes includes = PersonIncludes.Aliases); + + Task AnyAliasExist(string alias); } public class PersonRepository : IPersonRepository @@ -99,7 +129,7 @@ public class PersonRepository : IPersonRepository } - public async Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null) + public async Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null, PersonIncludes includes = PersonIncludes.Aliases) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); @@ -113,6 +143,7 @@ public class PersonRepository : IPersonRepository .Where(s => userLibs.Contains(s.LibraryId)) .RestrictAgainstAgeRestriction(ageRating) .SelectMany(s => s.Metadata.People.Select(p => p.Person)) + .Includes(includes) .Distinct() .OrderBy(p => p.Name) .AsNoTracking() @@ -193,27 +224,41 @@ public class PersonRepository : IPersonRepository return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } - public async Task GetPersonById(int personId) + public async Task GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None) { return await _context.Person.Where(p => p.Id == personId) + .Includes(includes) .FirstOrDefaultAsync(); } - public async Task GetPersonDtoByName(string name, int userId) + public async Task GetPersonDtoByName(string name, int userId, PersonIncludes includes = PersonIncludes.Aliases) { var normalized = name.ToNormalized(); var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); return await _context.Person .Where(p => p.NormalizedName == normalized) + .Includes(includes) .RestrictAgainstAgeRestriction(ageRating) .ProjectTo(_mapper.ConfigurationProvider) .FirstOrDefaultAsync(); } + public Task GetPersonByNameOrAliasAsync(string name, PersonIncludes includes = PersonIncludes.Aliases) + { + var normalized = name.ToNormalized(); + return _context.Person + .Includes(includes) + .Where(p => p.NormalizedName == normalized || p.Aliases.Any(pa => pa.NormalizedAlias == normalized)) + .FirstOrDefaultAsync(); + } + public async Task IsNameUnique(string name) { - return !(await _context.Person.AnyAsync(p => p.Name == name)); + // Should this use Normalized to check? + return !(await _context.Person + .Includes(PersonIncludes.Aliases) + .AnyAsync(p => p.Name == name || p.Aliases.Any(pa => pa.Alias == name))); } public async Task> GetSeriesKnownFor(int personId) @@ -245,45 +290,69 @@ public class PersonRepository : IPersonRepository .ToListAsync(); } - public async Task> GetPeopleByNames(List normalizedNames) + public async Task> GetPeopleByNames(List normalizedNames, PersonIncludes includes = PersonIncludes.Aliases) { return await _context.Person - .Where(p => normalizedNames.Contains(p.NormalizedName)) + .Includes(includes) + .Where(p => normalizedNames.Contains(p.NormalizedName) || p.Aliases.Any(pa => normalizedNames.Contains(pa.NormalizedAlias))) .OrderBy(p => p.Name) .ToListAsync(); } - public async Task GetPersonByAniListId(int aniListId) + public async Task GetPersonByAniListId(int aniListId, PersonIncludes includes = PersonIncludes.Aliases) { return await _context.Person .Where(p => p.AniListId == aniListId) + .Includes(includes) .FirstOrDefaultAsync(); } - public async Task> GetAllPeople() + public async Task> SearchPeople(string searchQuery, PersonIncludes includes = PersonIncludes.Aliases) + { + searchQuery = searchQuery.ToNormalized(); + + return await _context.Person + .Includes(includes) + .Where(p => EF.Functions.Like(p.Name, $"%{searchQuery}%") + || p.Aliases.Any(pa => EF.Functions.Like(pa.Alias, $"%{searchQuery}%"))) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + + public async Task AnyAliasExist(string alias) + { + return await _context.PersonAlias.AnyAsync(pa => pa.NormalizedAlias == alias.ToNormalized()); + } + + + public async Task> GetAllPeople(PersonIncludes includes = PersonIncludes.Aliases) { return await _context.Person + .Includes(includes) .OrderBy(p => p.Name) .ToListAsync(); } - public async Task> GetAllPersonDtosAsync(int userId) + public async Task> GetAllPersonDtosAsync(int userId, PersonIncludes includes = PersonIncludes.Aliases) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); return await _context.Person + .Includes(includes) .OrderBy(p => p.Name) .RestrictAgainstAgeRestriction(ageRating) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } - public async Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role) + public async Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role, PersonIncludes includes = PersonIncludes.Aliases) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); return await _context.Person .Where(p => p.SeriesMetadataPeople.Any(smp => smp.Role == role) || p.ChapterPeople.Any(cp => cp.Role == role)) // Filter by role in both series and chapters + .Includes(includes) .OrderBy(p => p.Name) .RestrictAgainstAgeRestriction(ageRating) .ProjectTo(_mapper.ConfigurationProvider) diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index 6d4a14bd9..6992b2950 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Data.Misc; -using API.DTOs; +using API.DTOs.Person; using API.DTOs.ReadingLists; using API.Entities; using API.Entities.Enums; diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index d9c78c770..e04c944e3 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -15,6 +15,7 @@ using API.DTOs.Filtering; using API.DTOs.Filtering.v2; using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Metadata; +using API.DTOs.Person; using API.DTOs.ReadingLists; using API.DTOs.Recommendation; using API.DTOs.Scrobbling; @@ -455,11 +456,18 @@ public class SeriesRepository : ISeriesRepository .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - result.Persons = await _context.SeriesMetadata + // I can't work out how to map people in DB layer + var personIds = await _context.SeriesMetadata .SearchPeople(searchQuery, seriesIds) - .Take(maxRecords) - .OrderBy(t => t.NormalizedName) + .Select(p => p.Id) .Distinct() + .OrderBy(id => id) + .Take(maxRecords) + .ToListAsync(); + + result.Persons = await _context.Person + .Where(p => personIds.Contains(p.Id)) + .OrderBy(p => p.NormalizedName) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -475,8 +483,8 @@ public class SeriesRepository : ISeriesRepository .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - result.Files = new List(); - result.Chapters = new List(); + result.Files = []; + result.Chapters = (List) []; if (includeChapterAndFiles) diff --git a/API/Entities/Person/Person.cs b/API/Entities/Person/Person.cs index 8eed08f5c..ed57fd6d3 100644 --- a/API/Entities/Person/Person.cs +++ b/API/Entities/Person/Person.cs @@ -8,8 +8,7 @@ public class Person : IHasCoverImage public int Id { get; set; } public required string Name { get; set; } public required string NormalizedName { get; set; } - - //public ICollection Aliases { get; set; } = default!; + public ICollection Aliases { get; set; } = []; public string? CoverImage { get; set; } public bool CoverImageLocked { get; set; } @@ -47,8 +46,8 @@ public class Person : IHasCoverImage //public long MetronId { get; set; } = 0; // Relationships - public ICollection ChapterPeople { get; set; } = new List(); - public ICollection SeriesMetadataPeople { get; set; } = new List(); + public ICollection ChapterPeople { get; set; } = []; + public ICollection SeriesMetadataPeople { get; set; } = []; public void ResetColorScape() diff --git a/API/Entities/Person/PersonAlias.cs b/API/Entities/Person/PersonAlias.cs new file mode 100644 index 000000000..f053f608d --- /dev/null +++ b/API/Entities/Person/PersonAlias.cs @@ -0,0 +1,11 @@ +namespace API.Entities.Person; + +public class PersonAlias +{ + public int Id { get; set; } + public required string Alias { get; set; } + public required string NormalizedAlias { get; set; } + + public int PersonId { get; set; } + public Person Person { get; set; } +} diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index e004fcc25..e95c4f65e 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -53,6 +53,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs b/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs index cc40491d0..d7acf9381 100644 --- a/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs +++ b/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using API.Data.Misc; using API.Data.Repositories; @@ -49,23 +50,26 @@ public static class SearchQueryableExtensions // Get people from SeriesMetadata var peopleFromSeriesMetadata = queryable .Where(sm => seriesIds.Contains(sm.SeriesId)) - .SelectMany(sm => sm.People) - .Where(p => p.Person.Name != null && EF.Functions.Like(p.Person.Name, $"%{searchQuery}%")) - .Select(p => p.Person); + .SelectMany(sm => sm.People.Select(sp => sp.Person)) + .Where(p => + EF.Functions.Like(p.Name, $"%{searchQuery}%") || + p.Aliases.Any(pa => EF.Functions.Like(pa.Alias, $"%{searchQuery}%")) + ); - // Get people from ChapterPeople by navigating through Volume -> Series var peopleFromChapterPeople = queryable .Where(sm => seriesIds.Contains(sm.SeriesId)) .SelectMany(sm => sm.Series.Volumes) .SelectMany(v => v.Chapters) - .SelectMany(ch => ch.People) - .Where(cp => cp.Person.Name != null && EF.Functions.Like(cp.Person.Name, $"%{searchQuery}%")) - .Select(cp => cp.Person); + .SelectMany(ch => ch.People.Select(cp => cp.Person)) + .Where(p => + EF.Functions.Like(p.Name, $"%{searchQuery}%") || + p.Aliases.Any(pa => EF.Functions.Like(pa.Alias, $"%{searchQuery}%")) + ); // Combine both queries and ensure distinct results return peopleFromSeriesMetadata .Union(peopleFromChapterPeople) - .Distinct() + .Select(p => p) .OrderBy(p => p.NormalizedName); } diff --git a/API/Extensions/QueryExtensions/IncludesExtensions.cs b/API/Extensions/QueryExtensions/IncludesExtensions.cs index 864c4e5a1..bfc585455 100644 --- a/API/Extensions/QueryExtensions/IncludesExtensions.cs +++ b/API/Extensions/QueryExtensions/IncludesExtensions.cs @@ -1,7 +1,7 @@ using System.Linq; using API.Data.Repositories; using API.Entities; -using API.Entities.Metadata; +using API.Entities.Person; using Microsoft.EntityFrameworkCore; namespace API.Extensions.QueryExtensions; @@ -321,4 +321,25 @@ public static class IncludesExtensions return query.AsSplitQuery(); } + + public static IQueryable Includes(this IQueryable queryable, PersonIncludes includeFlags) + { + + if (includeFlags.HasFlag(PersonIncludes.Aliases)) + { + queryable = queryable.Include(p => p.Aliases); + } + + if (includeFlags.HasFlag(PersonIncludes.ChapterPeople)) + { + queryable = queryable.Include(p => p.ChapterPeople); + } + + if (includeFlags.HasFlag(PersonIncludes.SeriesPeople)) + { + queryable = queryable.Include(p => p.SeriesMetadataPeople); + } + + return queryable; + } } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 334403ab3..75183fdcd 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -15,6 +15,7 @@ using API.DTOs.KavitaPlus.Manage; using API.DTOs.KavitaPlus.Metadata; using API.DTOs.MediaErrors; using API.DTOs.Metadata; +using API.DTOs.Person; using API.DTOs.Progress; using API.DTOs.Reader; using API.DTOs.ReadingLists; @@ -68,7 +69,8 @@ public class AutoMapperProfiles : Profile CreateMap() .ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName)) .ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count)); - CreateMap(); + CreateMap() + .ForMember(dest => dest.Aliases, opt => opt.MapFrom(src => src.Aliases.Select(s => s.Alias))); CreateMap(); CreateMap(); CreateMap(); diff --git a/API/Helpers/Builders/PersonAliasBuilder.cs b/API/Helpers/Builders/PersonAliasBuilder.cs new file mode 100644 index 000000000..e54ea8975 --- /dev/null +++ b/API/Helpers/Builders/PersonAliasBuilder.cs @@ -0,0 +1,19 @@ +using API.Entities.Person; +using API.Extensions; + +namespace API.Helpers.Builders; + +public class PersonAliasBuilder : IEntityBuilder +{ + private readonly PersonAlias _alias; + public PersonAlias Build() => _alias; + + public PersonAliasBuilder(string name) + { + _alias = new PersonAlias() + { + Alias = name.Trim(), + NormalizedAlias = name.ToNormalized(), + }; + } +} diff --git a/API/Helpers/Builders/PersonBuilder.cs b/API/Helpers/Builders/PersonBuilder.cs index 492d79e17..afd0c84af 100644 --- a/API/Helpers/Builders/PersonBuilder.cs +++ b/API/Helpers/Builders/PersonBuilder.cs @@ -1,7 +1,5 @@ using System.Collections.Generic; -using API.Entities; -using API.Entities.Enums; -using API.Entities.Metadata; +using System.Linq; using API.Entities.Person; using API.Extensions; @@ -34,6 +32,20 @@ public class PersonBuilder : IEntityBuilder return this; } + public PersonBuilder WithAlias(string alias) + { + if (_person.Aliases.Any(a => a.NormalizedAlias.Equals(alias.ToNormalized()))) + { + return this; + } + + _person.Aliases.Add(new PersonAliasBuilder(alias).Build()); + + return this; + } + + + public PersonBuilder WithSeriesMetadata(SeriesMetadataPeople seriesMetadataPeople) { _person.SeriesMetadataPeople.Add(seriesMetadataPeople); diff --git a/API/Helpers/PersonHelper.cs b/API/Helpers/PersonHelper.cs index 07161e418..b71ff2c1a 100644 --- a/API/Helpers/PersonHelper.cs +++ b/API/Helpers/PersonHelper.cs @@ -17,6 +17,20 @@ namespace API.Helpers; public static class PersonHelper { + public static Dictionary ConstructNameAndAliasDictionary(IList people) + { + var dict = new Dictionary(); + foreach (var person in people) + { + dict.TryAdd(person.NormalizedName, person); + foreach (var alias in person.Aliases) + { + dict.TryAdd(alias.NormalizedAlias, person); + } + } + return dict; + } + public static async Task UpdateSeriesMetadataPeopleAsync(SeriesMetadata metadata, ICollection metadataPeople, IEnumerable chapterPeople, PersonRole role, IUnitOfWork unitOfWork) { @@ -38,7 +52,9 @@ public static class PersonHelper // Identify people to remove from metadataPeople var peopleToRemove = existingMetadataPeople - .Where(person => !peopleToAddSet.Contains(person.Person.NormalizedName)) + .Where(person => + !peopleToAddSet.Contains(person.Person.NormalizedName) && + !person.Person.Aliases.Any(pa => peopleToAddSet.Contains(pa.NormalizedAlias))) .ToList(); // Remove identified people from metadataPeople @@ -53,11 +69,7 @@ public static class PersonHelper .GetPeopleByNames(peopleToAdd.Select(p => p.NormalizedName).ToList()); // Prepare a dictionary for quick lookup of existing people by normalized name - var existingPeopleDict = new Dictionary(); - foreach (var person in existingPeopleInDb) - { - existingPeopleDict.TryAdd(person.NormalizedName, person); - } + var existingPeopleDict = ConstructNameAndAliasDictionary(existingPeopleInDb); // Track the people to attach (newly created people) var peopleToAttach = new List(); @@ -129,15 +141,12 @@ public static class PersonHelper var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedPeople); // Prepare a dictionary for quick lookup by normalized name - var existingPeopleDict = new Dictionary(); - foreach (var person in existingPeople) - { - existingPeopleDict.TryAdd(person.NormalizedName, person); - } + var existingPeopleDict = ConstructNameAndAliasDictionary(existingPeople); // Identify people to remove (those present in ChapterPeople but not in the new list) - foreach (var existingChapterPerson in existingChapterPeople - .Where(existingChapterPerson => !normalizedPeople.Contains(existingChapterPerson.Person.NormalizedName))) + var toRemove = existingChapterPeople + .Where(existingChapterPerson => !normalizedPeople.Contains(existingChapterPerson.Person.NormalizedName)); + foreach (var existingChapterPerson in toRemove) { chapter.People.Remove(existingChapterPerson); unitOfWork.PersonRepository.Remove(existingChapterPerson); diff --git a/API/I18N/en.json b/API/I18N/en.json index 6e37a3cd9..5916bc63e 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -212,6 +212,7 @@ "user-no-access-library-from-series": "User does not have access to the library this series belongs to", "series-restricted-age-restriction": "User is not allowed to view this series due to age restrictions", "kavitaplus-restricted": "This is restricted to Kavita+ only", + "aliases-have-overlap": "One or more of the aliases have overlap with other people, cannot update", "volume-num": "Volume {0}", "book-num": "Book {0}", diff --git a/API/Services/PersonService.cs b/API/Services/PersonService.cs new file mode 100644 index 000000000..ff0049cbe --- /dev/null +++ b/API/Services/PersonService.cs @@ -0,0 +1,147 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Entities.Person; +using API.Extensions; +using API.Helpers.Builders; + +namespace API.Services; + +public interface IPersonService +{ + /// + /// Adds src as an alias to dst, this is a destructive operation + /// + /// Merged person + /// Remaining person + /// The entities passed as arguments **must** include all relations + /// + Task MergePeopleAsync(Person src, Person dst); + + /// + /// Adds the alias to the person, requires that the aliases are not shared with anyone else + /// + /// This method does NOT commit changes + /// + /// + /// + Task UpdatePersonAliasesAsync(Person person, IList aliases); +} + +public class PersonService(IUnitOfWork unitOfWork): IPersonService +{ + + public async Task MergePeopleAsync(Person src, Person dst) + { + if (dst.Id == src.Id) return; + + if (string.IsNullOrWhiteSpace(dst.Description) && !string.IsNullOrWhiteSpace(src.Description)) + { + dst.Description = src.Description; + } + + if (dst.MalId == 0 && src.MalId != 0) + { + dst.MalId = src.MalId; + } + + if (dst.AniListId == 0 && src.AniListId != 0) + { + dst.AniListId = src.AniListId; + } + + if (dst.HardcoverId == null && src.HardcoverId != null) + { + dst.HardcoverId = src.HardcoverId; + } + + if (dst.Asin == null && src.Asin != null) + { + dst.Asin = src.Asin; + } + + if (dst.CoverImage == null && src.CoverImage != null) + { + dst.CoverImage = src.CoverImage; + } + + MergeChapterPeople(dst, src); + MergeSeriesMetadataPeople(dst, src); + + dst.Aliases.Add(new PersonAliasBuilder(src.Name).Build()); + + foreach (var alias in src.Aliases) + { + dst.Aliases.Add(alias); + } + + unitOfWork.PersonRepository.Remove(src); + unitOfWork.PersonRepository.Update(dst); + await unitOfWork.CommitAsync(); + } + + private static void MergeChapterPeople(Person dst, Person src) + { + + foreach (var chapter in src.ChapterPeople) + { + var alreadyPresent = dst.ChapterPeople + .Any(x => x.ChapterId == chapter.ChapterId && x.Role == chapter.Role); + + if (alreadyPresent) continue; + + dst.ChapterPeople.Add(new ChapterPeople + { + Role = chapter.Role, + ChapterId = chapter.ChapterId, + Person = dst, + KavitaPlusConnection = chapter.KavitaPlusConnection, + OrderWeight = chapter.OrderWeight, + }); + } + } + + private static void MergeSeriesMetadataPeople(Person dst, Person src) + { + foreach (var series in src.SeriesMetadataPeople) + { + var alreadyPresent = dst.SeriesMetadataPeople + .Any(x => x.SeriesMetadataId == series.SeriesMetadataId && x.Role == series.Role); + + if (alreadyPresent) continue; + + dst.SeriesMetadataPeople.Add(new SeriesMetadataPeople + { + SeriesMetadataId = series.SeriesMetadataId, + Role = series.Role, + Person = dst, + KavitaPlusConnection = series.KavitaPlusConnection, + OrderWeight = series.OrderWeight, + }); + } + } + + public async Task UpdatePersonAliasesAsync(Person person, IList aliases) + { + var normalizedAliases = aliases + .Select(a => a.ToNormalized()) + .Where(a => !string.IsNullOrEmpty(a) && a != person.NormalizedName) + .ToList(); + + if (normalizedAliases.Count == 0) + { + person.Aliases = []; + return true; + } + + var others = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedAliases); + others = others.Where(p => p.Id != person.Id).ToList(); + + if (others.Count != 0) return false; + + person.Aliases = aliases.Select(a => new PersonAliasBuilder(a).Build()).ToList(); + + return true; + } +} diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index f9af923a2..a0c88b16d 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -10,6 +10,7 @@ using API.DTOs.Collection; using API.DTOs.KavitaPlus.ExternalMetadata; using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Metadata.Matching; +using API.DTOs.Person; using API.DTOs.Recommendation; using API.DTOs.Scrobbling; using API.DTOs.SeriesDetail; @@ -17,8 +18,10 @@ using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; using API.Entities.MetadataMatching; +using API.Entities.Person; using API.Extensions; using API.Helpers; +using API.Helpers.Builders; using API.Services.Tasks.Metadata; using API.Services.Tasks.Scanner.Parser; using API.SignalR; @@ -614,12 +617,8 @@ public class ExternalMetadataService : IExternalMetadataService madeModification = await UpdateTags(series, settings, externalMetadata, processedTags) || madeModification; madeModification = UpdateAgeRating(series, settings, processedGenres.Concat(processedTags)) || madeModification; - var staff = (externalMetadata.Staff ?? []).Select(s => - { - s.Name = settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}"; + var staff = await SetNameAndAddAliases(settings, externalMetadata.Staff); - return s; - }).ToList(); madeModification = await UpdateWriters(series, settings, staff) || madeModification; madeModification = await UpdateArtists(series, settings, staff) || madeModification; madeModification = await UpdateCharacters(series, settings, externalMetadata.Characters) || madeModification; @@ -632,6 +631,49 @@ public class ExternalMetadataService : IExternalMetadataService return madeModification; } + private async Task> SetNameAndAddAliases(MetadataSettingsDto settings, IList? staff) + { + if (staff == null || staff.Count == 0) return []; + + var nameMappings = staff.Select(s => new + { + Staff = s, + PreferredName = settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}", + AlternativeName = !settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}" + }).ToList(); + + var preferredNames = nameMappings.Select(n => n.PreferredName.ToNormalized()).Distinct().ToList(); + var alternativeNames = nameMappings.Select(n => n.AlternativeName.ToNormalized()).Distinct().ToList(); + + var existingPeople = await _unitOfWork.PersonRepository.GetPeopleByNames(preferredNames.Union(alternativeNames).ToList()); + var existingPeopleDictionary = PersonHelper.ConstructNameAndAliasDictionary(existingPeople); + + var modified = false; + foreach (var mapping in nameMappings) + { + mapping.Staff.Name = mapping.PreferredName; + + if (existingPeopleDictionary.ContainsKey(mapping.PreferredName.ToNormalized())) + { + continue; + } + + + if (existingPeopleDictionary.TryGetValue(mapping.AlternativeName.ToNormalized(), out var person)) + { + modified = true; + person.Aliases.Add(new PersonAliasBuilder(mapping.PreferredName).Build()); + } + } + + if (modified) + { + await _unitOfWork.CommitAsync(); + } + + return [.. staff]; + } + private static void GenerateGenreAndTagLists(ExternalSeriesDetailDto externalMetadata, MetadataSettingsDto settings, ref List processedTags, ref List processedGenres) { diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 805b3b06f..426a8de3f 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -7,6 +7,7 @@ using API.Comparators; using API.Data; using API.Data.Repositories; using API.DTOs; +using API.DTOs.Person; using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; @@ -361,8 +362,7 @@ public class SeriesService : ISeriesService var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames); // Use a dictionary for quick lookups - var existingPeopleDictionary = existingPeople.DistinctBy(p => p.NormalizedName) - .ToDictionary(p => p.NormalizedName, p => p); + var existingPeopleDictionary = PersonHelper.ConstructNameAndAliasDictionary(existingPeople); // List to track people that will be added to the metadata var peopleToAdd = new List(); diff --git a/API/Services/Tasks/Metadata/CoverDbService.cs b/API/Services/Tasks/Metadata/CoverDbService.cs index cebf08b97..d58b225a5 100644 --- a/API/Services/Tasks/Metadata/CoverDbService.cs +++ b/API/Services/Tasks/Metadata/CoverDbService.cs @@ -579,7 +579,7 @@ public class CoverDbService : ICoverDbService else { _directoryService.DeleteFiles([tempFullPath]); - series.CoverImage = Path.GetFileName(existingPath); + return; } } catch (Exception ex) diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index de9818b79..ba967d8a6 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -1,5 +1,6 @@ using System; using API.DTOs.Update; +using API.Entities.Person; using API.Extensions; using API.Services.Plus; @@ -147,6 +148,10 @@ public static class MessageFactory /// Volume is removed from server /// public const string VolumeRemoved = "VolumeRemoved"; + /// + /// A Person merged has been merged into another + /// + public const string PersonMerged = "PersonMerged"; public static SignalRMessage DashboardUpdateEvent(int userId) { @@ -661,4 +666,17 @@ public static class MessageFactory EventType = ProgressEventType.Single, }; } + + public static SignalRMessage PersonMergedMessage(Person dst, Person src) + { + return new SignalRMessage() + { + Name = PersonMerged, + Body = new + { + srcId = src.Id, + dstName = dst.Name, + }, + }; + } } diff --git a/UI/Web/src/_series-detail-common.scss b/UI/Web/src/_series-detail-common.scss index f043dec17..efb54f860 100644 --- a/UI/Web/src/_series-detail-common.scss +++ b/UI/Web/src/_series-detail-common.scss @@ -13,7 +13,7 @@ } .subtitle { - color: lightgrey; + color: var(--detail-subtitle-color); font-weight: bold; font-size: 0.8rem; } diff --git a/UI/Web/src/app/_models/library/library.ts b/UI/Web/src/app/_models/library/library.ts index 74cabc658..06ba86cf2 100644 --- a/UI/Web/src/app/_models/library/library.ts +++ b/UI/Web/src/app/_models/library/library.ts @@ -6,6 +6,9 @@ export enum LibraryType { Book = 2, Images = 3, LightNovel = 4, + /** + * Comic (Legacy) + */ ComicVine = 5 } diff --git a/UI/Web/src/app/_models/metadata/person.ts b/UI/Web/src/app/_models/metadata/person.ts index c8a4c566e..6b098de19 100644 --- a/UI/Web/src/app/_models/metadata/person.ts +++ b/UI/Web/src/app/_models/metadata/person.ts @@ -22,6 +22,7 @@ export interface Person extends IHasCover { id: number; name: string; description: string; + aliases: Array; coverImage?: string; coverImageLocked: boolean; malId?: number; diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 6d2f7053e..0fef35b0e 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -116,7 +116,11 @@ export enum Action { /** * Match an entity with an upstream system */ - Match = 28 + Match = 28, + /** + * Merge two (or more?) entities + */ + Merge = 29, } /** @@ -819,6 +823,14 @@ export class ActionFactoryService { callback: this.dummyCallback, requiresAdmin: true, children: [], + }, + { + action: Action.Merge, + title: 'merge', + description: 'merge-person-tooltip', + callback: this.dummyCallback, + requiresAdmin: true, + children: [], } ]; diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index ea1819bd7..67f07f32e 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -109,7 +109,11 @@ export enum EVENTS { /** * A Progress event when a smart collection is synchronizing */ - SmartCollectionSync = 'SmartCollectionSync' + SmartCollectionSync = 'SmartCollectionSync', + /** + * A Person merged has been merged into another + */ + PersonMerged = 'PersonMerged', } export interface Message { @@ -336,6 +340,13 @@ export class MessageHubService { payload: resp.body }); }); + + this.hubConnection.on(EVENTS.PersonMerged, resp => { + this.messagesSource.next({ + event: EVENTS.PersonMerged, + payload: resp.body + }); + }) } stopHubConnection() { diff --git a/UI/Web/src/app/_services/person.service.ts b/UI/Web/src/app/_services/person.service.ts index 676aa6e71..0ac58b178 100644 --- a/UI/Web/src/app/_services/person.service.ts +++ b/UI/Web/src/app/_services/person.service.ts @@ -1,14 +1,12 @@ -import { Injectable } from '@angular/core'; -import { HttpClient, HttpParams } from "@angular/common/http"; +import {Injectable} from '@angular/core'; +import {HttpClient, HttpParams} from "@angular/common/http"; import {environment} from "../../environments/environment"; import {Person, PersonRole} from "../_models/metadata/person"; -import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; import {PaginatedResult} from "../_models/pagination"; import {Series} from "../_models/series"; import {map} from "rxjs/operators"; import {UtilityService} from "../shared/_services/utility.service"; import {BrowsePerson} from "../_models/person/browse-person"; -import {Chapter} from "../_models/chapter"; import {StandaloneChapter} from "../_models/standalone-chapter"; import {TextResonse} from "../_types/text-response"; @@ -29,6 +27,10 @@ export class PersonService { return this.httpClient.get(this.baseUrl + `person?name=${name}`); } + searchPerson(name: string) { + return this.httpClient.get>(this.baseUrl + `person/search?queryString=${encodeURIComponent(name)}`); + } + getRolesForPerson(personId: number) { return this.httpClient.get>(this.baseUrl + `person/roles?personId=${personId}`); } @@ -55,4 +57,15 @@ export class PersonService { downloadCover(personId: number) { return this.httpClient.post(this.baseUrl + 'person/fetch-cover?personId=' + personId, {}, TextResonse); } + + isValidAlias(personId: number, alias: string) { + return this.httpClient.get(this.baseUrl + `person/valid-alias?personId=${personId}&alias=${alias}`, TextResonse).pipe( + map(valid => valid + '' === 'true') + ); + } + + mergePerson(destId: number, srcId: number) { + return this.httpClient.post(this.baseUrl + 'person/merge', {destId, srcId}); + } + } diff --git a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts index 467452a9f..bda048341 100644 --- a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts +++ b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts @@ -483,7 +483,7 @@ export class EditChapterModalComponent implements OnInit { }; personSettings.addTransformFn = ((title: string) => { - return {id: 0, name: title, role: role, description: '', coverImage: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' }; + return {id: 0, name: title, aliases: [], role: role, description: '', coverImage: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' }; }); personSettings.trackByIdentityFn = (index, value) => value.name + (value.id + ''); diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts index c00d65c2a..623b13ec5 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts @@ -521,7 +521,7 @@ export class EditSeriesModalComponent implements OnInit { }; personSettings.addTransformFn = ((title: string) => { - return {id: 0, name: title, description: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' }; + return {id: 0, name: title, aliases: [], description: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' }; }); personSettings.trackByIdentityFn = (index, value) => value.name + (value.id + ''); diff --git a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html index 7c49b2934..9e0f26a0a 100644 --- a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html +++ b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html @@ -118,7 +118,14 @@ width="24px" [imageUrl]="imageService.getPersonImage(item.id)" [errorImage]="imageService.noPersonImage">
-
{{item.name}}
+
+ {{item.name}} +
+ @if (item.aliases.length > 0) { + + {{t('person-aka-status')}} + + }
@@ -206,7 +213,7 @@ } } - + diff --git a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.scss b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.scss index 296e9de45..19a2ef5c4 100644 --- a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.scss +++ b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.scss @@ -138,3 +138,7 @@ } } } + +.small-text { + font-size: 0.8rem; +} diff --git a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html index 4782529b3..f6ae1c6ae 100644 --- a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html +++ b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html @@ -96,6 +96,19 @@ +
  • + {{t(TabID.Aliases)}} + +
    {{t('aliases-label')}}
    +
    {{t('aliases-tooltip')}}
    + +
    +
  • +
  • {{t(TabID.CoverImage)}} diff --git a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts index 7db41ce13..74a20e951 100644 --- a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts +++ b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts @@ -1,6 +1,14 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service"; -import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; +import { + AbstractControl, + AsyncValidatorFn, + FormControl, + FormGroup, + ReactiveFormsModule, + ValidationErrors, + Validators +} from "@angular/forms"; import {Person} from "../../../_models/metadata/person"; import { NgbActiveModal, @@ -14,14 +22,16 @@ import { import {PersonService} from "../../../_services/person.service"; import {translate, TranslocoDirective} from '@jsverse/transloco'; import {CoverImageChooserComponent} from "../../../cards/cover-image-chooser/cover-image-chooser.component"; -import {forkJoin} from "rxjs"; +import {forkJoin, map, of} from "rxjs"; import {UploadService} from "../../../_services/upload.service"; import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component"; import {AccountService} from "../../../_services/account.service"; import {ToastrService} from "ngx-toastr"; +import {EditListComponent} from "../../../shared/edit-list/edit-list.component"; enum TabID { General = 'general-tab', + Aliases = 'aliases-tab', CoverImage = 'cover-image-tab', } @@ -37,7 +47,8 @@ enum TabID { NgbNavOutlet, CoverImageChooserComponent, SettingItemComponent, - NgbNavLink + NgbNavLink, + EditListComponent ], templateUrl: './edit-person-modal.component.html', styleUrl: './edit-person-modal.component.scss', @@ -117,6 +128,7 @@ export class EditPersonModalComponent implements OnInit { // @ts-ignore malId: this.editForm.get('malId')!.value === '' ? null : parseInt(this.editForm.get('malId').value, 10), hardcoverId: this.editForm.get('hardcoverId')!.value || '', + aliases: this.person.aliases, }; apis.push(this.personService.updatePerson(person)); @@ -165,4 +177,21 @@ export class EditPersonModalComponent implements OnInit { }); } + aliasValidator(): AsyncValidatorFn { + return (control: AbstractControl) => { + const name = control.value; + if (!name || name.trim().length === 0) { + return of(null); + } + + return this.personService.isValidAlias(this.person.id, name).pipe(map(valid => { + if (valid) { + return null; + } + + return { 'invalidAlias': {'alias': name} } as ValidationErrors; + })); + } + } + } diff --git a/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.html b/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.html new file mode 100644 index 000000000..2f4cb8b42 --- /dev/null +++ b/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.html @@ -0,0 +1,65 @@ + + + + + + + + diff --git a/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.scss b/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.ts b/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.ts new file mode 100644 index 000000000..865db0590 --- /dev/null +++ b/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.ts @@ -0,0 +1,101 @@ +import {Component, DestroyRef, EventEmitter, inject, Input, OnInit} from '@angular/core'; +import {Person} from "../../../_models/metadata/person"; +import {PersonService} from "../../../_services/person.service"; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {ToastrService} from "ngx-toastr"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {TypeaheadComponent} from "../../../typeahead/_components/typeahead.component"; +import {TypeaheadSettings} from "../../../typeahead/_models/typeahead-settings"; +import {map} from "rxjs/operators"; +import {UtilityService} from "../../../shared/_services/utility.service"; +import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component"; +import {BadgeExpanderComponent} from "../../../shared/badge-expander/badge-expander.component"; +import {FilterField} from "../../../_models/metadata/v2/filter-field"; +import {Observable, of} from "rxjs"; +import {Series} from "../../../_models/series"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {AsyncPipe} from "@angular/common"; + +@Component({ + selector: 'app-merge-person-modal', + imports: [ + TranslocoDirective, + TypeaheadComponent, + SettingItemComponent, + BadgeExpanderComponent, + AsyncPipe + ], + templateUrl: './merge-person-modal.component.html', + styleUrl: './merge-person-modal.component.scss' +}) +export class MergePersonModalComponent implements OnInit { + + private readonly personService = inject(PersonService); + public readonly utilityService = inject(UtilityService); + private readonly destroyRef = inject(DestroyRef); + private readonly modal = inject(NgbActiveModal); + protected readonly toastr = inject(ToastrService); + + typeAheadSettings!: TypeaheadSettings; + typeAheadUnfocus = new EventEmitter(); + + @Input({required: true}) person!: Person; + + mergee: Person | null = null; + knownFor$: Observable | null = null; + + save() { + if (!this.mergee) { + this.close(); + return; + } + + this.personService.mergePerson(this.person.id, this.mergee.id).subscribe(person => { + this.modal.close({success: true, person: person}); + }) + } + + close() { + this.modal.close({success: false, person: this.person}); + } + + ngOnInit(): void { + this.typeAheadSettings = new TypeaheadSettings(); + this.typeAheadSettings.minCharacters = 0; + this.typeAheadSettings.multiple = false; + this.typeAheadSettings.addIfNonExisting = false; + this.typeAheadSettings.id = "merge-person-modal-typeahead"; + this.typeAheadSettings.compareFn = (options: Person[], filter: string) => { + return options.filter(m => this.utilityService.filter(m.name, filter)); + } + this.typeAheadSettings.selectionCompareFn = (a: Person, b: Person) => { + return a.name == b.name; + } + this.typeAheadSettings.fetchFn = (filter: string) => { + if (filter.length == 0) return of([]); + + return this.personService.searchPerson(filter).pipe(map(people => { + return people.filter(p => this.utilityService.filter(p.name, filter) && p.id != this.person.id); + })); + }; + + this.typeAheadSettings.trackByIdentityFn = (index, value) => `${value.name}_${value.id}`; + } + + updatePerson(people: Person[]) { + if (people.length == 0) return; + + this.typeAheadUnfocus.emit(this.typeAheadSettings.id); + this.mergee = people[0]; + this.knownFor$ = this.personService.getSeriesMostKnownFor(this.mergee.id) + .pipe(takeUntilDestroyed(this.destroyRef)); + } + + protected readonly FilterField = FilterField; + + allNewAliases() { + if (!this.mergee) return []; + + return [this.mergee.name, ...this.mergee.aliases] + } +} diff --git a/UI/Web/src/app/person-detail/person-detail.component.html b/UI/Web/src/app/person-detail/person-detail.component.html index 507521642..1b99611e1 100644 --- a/UI/Web/src/app/person-detail/person-detail.component.html +++ b/UI/Web/src/app/person-detail/person-detail.component.html @@ -43,15 +43,43 @@
    - + + + + @if (person.aliases.length > 0) { + {{t('aka-title')}} +
    + + + {{item}} + + +
    + } + @if (roles$ | async; as roles) { -
    -
    {{t('all-roles')}}
    - @for(role of roles; track role) { - {{role | personRole}} - } -
    + @if (roles.length > 0) { + {{t('all-roles')}} +
    + + + {{item | personRole}} + + +
    + } + + + + + + + + + }
    diff --git a/UI/Web/src/app/person-detail/person-detail.component.ts b/UI/Web/src/app/person-detail/person-detail.component.ts index e2dc192cb..2a3f1d63c 100644 --- a/UI/Web/src/app/person-detail/person-detail.component.ts +++ b/UI/Web/src/app/person-detail/person-detail.component.ts @@ -1,31 +1,31 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, - Component, DestroyRef, + Component, + DestroyRef, ElementRef, - Inject, - inject, OnInit, + inject, + OnInit, ViewChild } from '@angular/core'; import {ActivatedRoute, Router} from "@angular/router"; import {PersonService} from "../_services/person.service"; import {BehaviorSubject, EMPTY, Observable, switchMap, tap} from "rxjs"; import {Person, PersonRole} from "../_models/metadata/person"; -import {AsyncPipe, NgStyle} from "@angular/common"; +import {AsyncPipe} from "@angular/common"; import {ImageComponent} from "../shared/image/image.component"; import {ImageService} from "../_services/image.service"; import { SideNavCompanionBarComponent } from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component"; import {ReadMoreComponent} from "../shared/read-more/read-more.component"; -import {TagBadgeComponent, TagBadgeCursor} from "../shared/tag-badge/tag-badge.component"; +import {TagBadgeCursor} from "../shared/tag-badge/tag-badge.component"; import {PersonRolePipe} from "../_pipes/person-role.pipe"; import {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.component"; -import {SeriesCardComponent} from "../cards/series-card/series-card.component"; import {FilterComparison} from "../_models/metadata/v2/filter-comparison"; import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service"; import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; -import {allPeople, personRoleForFilterField} from "../_models/metadata/v2/filter-field"; +import {allPeople, FilterField, personRoleForFilterField} from "../_models/metadata/v2/filter-field"; import {Series} from "../_models/series"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {FilterCombination} from "../_models/metadata/v2/filter-combination"; @@ -42,28 +42,38 @@ import {DefaultModalOptions} from "../_models/default-modal-options"; import {ToastrService} from "ngx-toastr"; import {LicenseService} from "../_services/license.service"; import {SafeUrlPipe} from "../_pipes/safe-url.pipe"; +import {MergePersonModalComponent} from "./_modal/merge-person-modal/merge-person-modal.component"; +import {EVENTS, MessageHubService} from "../_services/message-hub.service"; +import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component"; + +interface PersonMergeEvent { + srcId: number, + dstId: number, + dstName: number, +} + @Component({ - selector: 'app-person-detail', - imports: [ - AsyncPipe, - ImageComponent, - SideNavCompanionBarComponent, - ReadMoreComponent, - TagBadgeComponent, - PersonRolePipe, - CarouselReelComponent, - CardItemComponent, - CardActionablesComponent, - TranslocoDirective, - ChapterCardComponent, - SafeUrlPipe - ], - templateUrl: './person-detail.component.html', - styleUrl: './person-detail.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'app-person-detail', + imports: [ + AsyncPipe, + ImageComponent, + SideNavCompanionBarComponent, + ReadMoreComponent, + PersonRolePipe, + CarouselReelComponent, + CardItemComponent, + CardActionablesComponent, + TranslocoDirective, + ChapterCardComponent, + SafeUrlPipe, + BadgeExpanderComponent + ], + templateUrl: './person-detail.component.html', + styleUrl: './person-detail.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush }) -export class PersonDetailComponent { +export class PersonDetailComponent implements OnInit { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly filterUtilityService = inject(FilterUtilitiesService); @@ -77,6 +87,7 @@ export class PersonDetailComponent { protected readonly licenseService = inject(LicenseService); private readonly themeService = inject(ThemeService); private readonly toastr = inject(ToastrService); + private readonly messageHubService = inject(MessageHubService) protected readonly TagBadgeCursor = TagBadgeCursor; @@ -88,11 +99,11 @@ export class PersonDetailComponent { roles$: Observable | null = null; roles: PersonRole[] | null = null; works$: Observable | null = null; - defaultSummaryText = 'No information about this Person'; filter: SeriesFilterV2 | null = null; personActions: Array> = this.actionService.getPersonActions(this.handleAction.bind(this)); chaptersByRole: any = {}; anilistUrl: string = ''; + private readonly personSubject = new BehaviorSubject(null); protected readonly person$ = this.personSubject.asObservable().pipe(tap(p => { if (p?.aniListId) { @@ -118,43 +129,58 @@ export class PersonDetailComponent { return this.personService.get(personName); }), tap((person) => { - if (person == null) { this.toastr.error(translate('toasts.unauthorized-1')); this.router.navigateByUrl('/home'); return; } - this.person = person; - this.personSubject.next(person); // emit the person data for subscribers - this.themeService.setColorScape(person.primaryColor || '', person.secondaryColor); - - // Fetch roles and process them - this.roles$ = this.personService.getRolesForPerson(this.person.id).pipe( - tap(roles => { - this.roles = roles; - this.filter = this.createFilter(roles); - this.chaptersByRole = {}; // Reset chaptersByRole for each person - - // Populate chapters by role - roles.forEach(role => { - this.chaptersByRole[role] = this.personService.getChaptersByRole(person.id, role) - .pipe(takeUntilDestroyed(this.destroyRef)); - }); - this.cdRef.markForCheck(); - }), - takeUntilDestroyed(this.destroyRef) - ); - - // Fetch series known for this person - this.works$ = this.personService.getSeriesMostKnownFor(person.id).pipe( - takeUntilDestroyed(this.destroyRef) - ); + this.setPerson(person); }), takeUntilDestroyed(this.destroyRef) ).subscribe(); } + ngOnInit(): void { + this.messageHubService.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(message => { + if (message.event !== EVENTS.PersonMerged) return; + + const event = message.payload as PersonMergeEvent; + if (event.srcId !== this.person?.id) return; + + this.router.navigate(['person', event.dstName]); + }); + } + + private setPerson(person: Person) { + this.person = person; + this.personSubject.next(person); // emit the person data for subscribers + this.themeService.setColorScape(person.primaryColor || '', person.secondaryColor); + + // Fetch roles and process them + this.roles$ = this.personService.getRolesForPerson(this.person.id).pipe( + tap(roles => { + this.roles = roles; + this.filter = this.createFilter(roles); + this.chaptersByRole = {}; // Reset chaptersByRole for each person + + // Populate chapters by role + roles.forEach(role => { + this.chaptersByRole[role] = this.personService.getChaptersByRole(person.id, role) + .pipe(takeUntilDestroyed(this.destroyRef)); + }); + this.cdRef.markForCheck(); + }), + takeUntilDestroyed(this.destroyRef) + ); + + // Fetch series known for this person + this.works$ = this.personService.getSeriesMostKnownFor(person.id).pipe( + takeUntilDestroyed(this.destroyRef) + ); + + } + createFilter(roles: PersonRole[]) { const filter: SeriesFilterV2 = this.filterUtilityService.createSeriesV2Filter(); filter.combination = FilterCombination.Or; @@ -229,14 +255,34 @@ export class PersonDetailComponent { } }); break; + case (Action.Merge): + this.mergePersonAction(); + break; default: break; } } + private mergePersonAction() { + const ref = this.modalService.open(MergePersonModalComponent, DefaultModalOptions); + ref.componentInstance.person = this.person; + + ref.closed.subscribe(r => { + if (r.success) { + // Reload the person data, as relations may have changed + this.personService.get(r.person.name).subscribe(person => { + this.setPerson(person!); + this.cdRef.markForCheck(); + }) + } + }); + } + performAction(action: ActionItem) { if (typeof action.callback === 'function') { action.callback(action, this.person); } } + + protected readonly FilterField = FilterField; } 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 6d39b0b28..1696aa5f0 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 @@ -130,7 +130,7 @@ {{t('publication-status-title')}}
    @if (seriesMetadata.publicationStatus | publicationStatus; as pubStatus) { - {{pubStatus}} diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.scss b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.scss index 26ef0aabc..158f2ce01 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.scss +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.scss @@ -30,3 +30,7 @@ :host ::ng-deep .card-actions.btn-actions .btn { padding: 0.375rem 0.75rem; } + +.font-size { + font-size: 0.8rem; +} diff --git a/UI/Web/src/app/shared/badge-expander/badge-expander.component.scss b/UI/Web/src/app/shared/badge-expander/badge-expander.component.scss index cf2445645..342ab4431 100644 --- a/UI/Web/src/app/shared/badge-expander/badge-expander.component.scss +++ b/UI/Web/src/app/shared/badge-expander/badge-expander.component.scss @@ -5,4 +5,11 @@ .collapsed { height: 35px; overflow: hidden; -} \ No newline at end of file +} + +::ng-deep .badge-expander .content { + a, + span { + font-size: 0.8rem; + } +} 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 0231252d6..930f9720a 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,6 +1,7 @@
    - @for(item of ItemsArray.controls; let i = $index; track i) { + + @for(item of ItemsArray.controls; let i = $index; track item; let last = $last) {
    @@ -11,21 +12,30 @@ [formControlName]="i" id="item--{{i}}" > + @if (item.dirty && item.touched && errorMessage) { + @if (item.status === "INVALID") { +
    + {{errorMessage}} +
    + } + }
    -
    - +
    + @if (last){ + + }
    } 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 6d21549e8..c5b3d121e 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,7 +9,7 @@ import { OnInit, Output } from '@angular/core'; -import {FormArray, FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; +import {AsyncValidatorFn, FormArray, FormControl, FormGroup, ReactiveFormsModule, ValidatorFn} from "@angular/forms"; import {TranslocoDirective} from "@jsverse/transloco"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators"; @@ -28,6 +28,10 @@ export class EditListComponent implements OnInit { @Input({required: true}) items: Array = []; @Input({required: true}) label = ''; + @Input() validators: ValidatorFn[] = [] + @Input() asyncValidators: AsyncValidatorFn[] = []; + // TODO: Make this more dynamic based on which validator failed + @Input() errorMessage: string | null = null; @Output() updateItems = new EventEmitter>(); form: FormGroup = new FormGroup({items: new FormArray([])}); @@ -39,6 +43,9 @@ export class EditListComponent implements OnInit { ngOnInit() { this.items.forEach(item => this.addItem(item)); + if (this.items.length === 0) { + this.addItem(""); + } this.form.valueChanges.pipe( @@ -51,7 +58,7 @@ export class EditListComponent implements OnInit { } createItemControl(value: string = ''): FormControl { - return new FormControl(value, []); + return new FormControl(value, this.validators, this.asyncValidators); } add() { @@ -69,6 +76,7 @@ export class EditListComponent implements OnInit { if (this.ItemsArray.length === 1) { this.ItemsArray.at(0).setValue(''); this.emit(); + this.cdRef.markForCheck(); return; } 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 d8a0ff752..ab8d46753 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 @@ -130,7 +130,8 @@ export class LibrarySettingsModalComponent implements OnInit { get IsMetadataDownloadEligible() { const libType = parseInt(this.libraryForm.get('type')?.value + '', 10) as LibraryType; - return libType === LibraryType.Manga || libType === LibraryType.LightNovel || libType === LibraryType.ComicVine; + return libType === LibraryType.Manga || libType === LibraryType.LightNovel + || libType === LibraryType.ComicVine || libType === LibraryType.Comic; } ngOnInit(): void { diff --git a/UI/Web/src/app/typeahead/_components/typeahead.component.ts b/UI/Web/src/app/typeahead/_components/typeahead.component.ts index 223676b3a..17dbc7b4c 100644 --- a/UI/Web/src/app/typeahead/_components/typeahead.component.ts +++ b/UI/Web/src/app/typeahead/_components/typeahead.component.ts @@ -72,6 +72,10 @@ export class TypeaheadComponent implements OnInit { * When triggered, will focus the input if the passed string matches the id */ @Input() focus: EventEmitter | undefined; + /** + * When triggered, will unfocus the input if the passed string matches the id + */ + @Input() unFocus: EventEmitter | undefined; @Output() selectedData = new EventEmitter(); @Output() newItemAdded = new EventEmitter(); // eslint-disable-next-line @angular-eslint/no-output-on-prefix @@ -113,6 +117,13 @@ export class TypeaheadComponent implements OnInit { }); } + if (this.unFocus) { + this.unFocus.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((id: string) => { + if (this.settings.id !== id) return; + this.hasFocus = false; + }); + } + this.init(); } diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index e460e3ffa..19d4443b6 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -1003,7 +1003,7 @@ "save": "{{common.save}}", "no-results": "Unable to find a match. Try adding the url from a supported provider and retry.", "query-label": "Query", - "query-tooltip": "Enter series name, AniList/MyAnimeList url. Urls will use a direct lookup.", + "query-tooltip": "Enter series name, AniList/MyAnimeList/ComicBookRoundup url. Urls will use a direct lookup.", "dont-match-label": "Do not Match", "dont-match-tooltip": "Opt this series from matching and scrobbling", "search": "Search" @@ -1103,12 +1103,14 @@ }, "person-detail": { + "aka-title": "Also known as ", "known-for-title": "Known For", "individual-role-title": "As a {{role}}", "browse-person-title": "All Works of {{name}}", "browse-person-by-role-title": "All Works of {{name}} as a {{role}}", "all-roles": "Roles", - "anilist-url": "{{edit-person-modal.anilist-tooltip}}" + "anilist-url": "{{edit-person-modal.anilist-tooltip}}", + "no-info": "No information about this Person" }, "library-settings-modal": { @@ -1857,7 +1859,8 @@ "logout": "Logout", "all-filters": "Smart Filters", "nav-link-header": "Navigation Options", - "close": "{{common.close}}" + "close": "{{common.close}}", + "person-aka-status": "Matches an alias" }, "promoted-icon": { @@ -2246,6 +2249,7 @@ "title": "{{personName}} Details", "general-tab": "{{edit-series-modal.general-tab}}", "cover-image-tab": "{{edit-series-modal.cover-image-tab}}", + "aliases-tab": "Aliases", "loading": "{{common.loading}}", "close": "{{common.close}}", "name-label": "{{edit-series-modal.name-label}}", @@ -2263,7 +2267,20 @@ "cover-image-description": "{{edit-series-modal.cover-image-description}}", "cover-image-description-extra": "Alternatively you can download a cover from CoversDB if available.", "save": "{{common.save}}", - "download-coversdb": "Download from CoversDB" + "download-coversdb": "Download from CoversDB", + "aliases-label": "Edit aliases", + "alias-overlap": "This alias already points towards another person or is the name of this person, consider merging them.", + "aliases-tooltip": "When a series is tagged with an alias of a person, the person is assigned rather than creating a new person. When deleting an alias, you'll have to rescan the series for the change to be picked up." + }, + + "merge-person-modal": { + "title": "{{personName}}", + "close": "{{common.close}}", + "save": "{{common.save}}", + "src": "Merge Person", + "merge-warning": "If you proceed, the selected person will be removed. The selected person's name will be added as an alias, and all their roles will be transferred.", + "alias-title": "New aliases", + "known-for-title": "Known for" }, "day-breakdown": { @@ -2781,7 +2798,8 @@ "match-tooltip": "Match Series with Kavita+ manually", "reorder": "Reorder", "rename": "Rename", - "rename-tooltip": "Rename the Smart Filter" + "rename-tooltip": "Rename the Smart Filter", + "merge": "Merge" }, "preferences": { diff --git a/UI/Web/src/theme/themes/dark.scss b/UI/Web/src/theme/themes/dark.scss index d06e4e4d0..f57c52f29 100644 --- a/UI/Web/src/theme/themes/dark.scss +++ b/UI/Web/src/theme/themes/dark.scss @@ -436,4 +436,7 @@ --login-input-font-family: 'League Spartan', sans-serif; --login-input-placeholder-opacity: 0.5; --login-input-placeholder-color: #fff; + + /** Series Detail **/ + --detail-subtitle-color: lightgrey; } From 005c1bf60b591245d625455ea4c57a08017beb27 Mon Sep 17 00:00:00 2001 From: majora2007 Date: Fri, 9 May 2025 22:18:55 +0000 Subject: [PATCH 12/57] Bump versions by dotnet-bump-version. --- Kavita.Common/Kavita.Common.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 2c9ab6dc0..029166254 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -3,7 +3,7 @@ net9.0 kavitareader.com Kavita - 0.8.6.8 + 0.8.6.9 en true From 574cf4b78e988c11cc753c41b609b147ea65a8ea Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 9 May 2025 22:20:05 +0000 Subject: [PATCH 13/57] Update OpenAPI documentation --- openapi.json | 229 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 227 insertions(+), 2 deletions(-) diff --git a/openapi.json b/openapi.json index 11a839f53..68a302fc7 100644 --- a/openapi.json +++ b/openapi.json @@ -2,12 +2,12 @@ "openapi": "3.0.4", "info": { "title": "Kavita", - "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.6.7", + "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.6.8", "license": { "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.8.6.7" + "version": "0.8.6.8" }, "servers": [ { @@ -5469,6 +5469,55 @@ } } }, + "/api/Person/search": { + "get": { + "tags": [ + "Person" + ], + "summary": "Find a person by name or alias against a query string", + "parameters": [ + { + "name": "queryString", + "in": "query", + "description": "", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + } + } + } + } + } + } + } + }, "/api/Person/roles": { "get": { "tags": [ @@ -5844,6 +5893,105 @@ } } }, + "/api/Person/merge": { + "post": { + "tags": [ + "Person" + ], + "summary": "Merges Persons into one, this action is irreversible", + "requestBody": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonMergeDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PersonMergeDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/PersonMergeDto" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PersonDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PersonDto" + } + } + } + } + } + } + }, + "/api/Person/valid-alias": { + "get": { + "tags": [ + "Person" + ], + "summary": "Ensure the alias is valid to be added. For example, the alias cannot be on another person or be the same as the current person name/alias.", + "parameters": [ + { + "name": "personId", + "in": "query", + "description": "", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "alias", + "in": "query", + "description": "", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "boolean" + } + }, + "application/json": { + "schema": { + "type": "boolean" + } + }, + "text/json": { + "schema": { + "type": "boolean" + } + } + } + } + } + } + }, "/api/Plugin/authenticate": { "post": { "tags": [ @@ -16660,6 +16808,13 @@ "type": "string", "nullable": true }, + "aliases": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, "description": { "type": "string", "nullable": true @@ -20905,6 +21060,13 @@ "type": "string", "nullable": true }, + "aliases": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonAlias" + }, + "nullable": true + }, "coverImage": { "type": "string", "nullable": true @@ -20962,6 +21124,35 @@ }, "additionalProperties": false }, + "PersonAlias": { + "required": [ + "alias", + "normalizedAlias" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "alias": { + "type": "string", + "nullable": true + }, + "normalizedAlias": { + "type": "string", + "nullable": true + }, + "personId": { + "type": "integer", + "format": "int32" + }, + "person": { + "$ref": "#/components/schemas/Person" + } + }, + "additionalProperties": false + }, "PersonDto": { "required": [ "name" @@ -20991,6 +21182,13 @@ "type": "string", "nullable": true }, + "aliases": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, "description": { "type": "string", "nullable": true @@ -21018,6 +21216,26 @@ }, "additionalProperties": false }, + "PersonMergeDto": { + "required": [ + "destId", + "srcId" + ], + "type": "object", + "properties": { + "destId": { + "type": "integer", + "description": "The id of the person being merged into", + "format": "int32" + }, + "srcId": { + "type": "integer", + "description": "The id of the person being merged. This person will be removed, and become an alias of API.DTOs.PersonMergeDto.DestId", + "format": "int32" + } + }, + "additionalProperties": false + }, "PersonalToCDto": { "required": [ "chapterId", @@ -25071,6 +25289,13 @@ "minLength": 1, "type": "string" }, + "aliases": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, "description": { "type": "string", "nullable": true From 70f00895e82d41880f1f945c8c1f37176fccf04c Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Sat, 10 May 2025 15:57:14 -0600 Subject: [PATCH 14/57] Random Stuff (#3798) --- API.Tests/API.Tests.csproj | 6 +- API/API.csproj | 31 +- .../ExternalMetadata/MatchSeriesRequestDto.cs | 10 +- API/Middleware/SecurityMiddleware.cs | 3 +- API/Program.cs | 36 ++- API/Services/Plus/ExternalMetadataService.cs | 10 +- API/Services/Tasks/Metadata/CoverDbService.cs | 3 +- API/Services/Tasks/Scanner/LibraryWatcher.cs | 2 +- API/Services/Tasks/VersionUpdaterService.cs | 20 +- API/Startup.cs | 18 +- Kavita.Common/Kavita.Common.csproj | 2 +- .../app/_services/action-factory.service.ts | 290 +++++++++++++----- UI/Web/src/app/_services/action.service.ts | 3 +- .../src/app/_services/statistics.service.ts | 42 +-- .../actionable-modal.component.html | 6 +- .../actionable-modal.component.ts | 7 +- .../card-actionables.component.html | 90 +++--- .../card-actionables.component.scss | 22 +- .../card-actionables.component.ts | 118 ++++--- .../manage-library.component.html | 22 +- .../manage-library.component.ts | 19 +- .../manage-logs/manage-logs.component.html | 11 - .../manage-logs/manage-logs.component.scss | 0 .../manage-logs/manage-logs.component.ts | 71 ----- .../update-section.component.ts | 2 - .../bulk-operations.component.html | 2 +- .../bulk-operations.component.ts | 31 +- .../card-detail-layout.component.html | 2 +- .../cards/card-item/card-item.component.html | 2 +- .../cards/card-item/card-item.component.ts | 4 - .../chapter-card/chapter-card.component.html | 2 +- .../chapter-card/chapter-card.component.ts | 46 +-- .../person-card/person-card.component.html | 2 +- .../person-card/person-card.component.ts | 21 +- .../series-card/series-card.component.html | 2 +- .../series-card/series-card.component.ts | 17 +- .../volume-card/volume-card.component.html | 2 +- .../volume-card/volume-card.component.ts | 29 +- .../carousel-reel.component.html | 2 +- .../chapter-detail.component.html | 2 +- .../chapter-detail.component.ts | 17 +- .../collection-detail.component.html | 8 +- .../collection-detail.component.ts | 10 +- .../library-detail.component.html | 3 +- .../library-detail.component.ts | 2 - .../grouped-typeahead.component.html | 4 + .../grouped-typeahead.component.scss | 11 + .../grouped-typeahead.component.ts | 25 +- .../person-detail.component.html | 2 +- .../person-detail/person-detail.component.ts | 10 +- .../reading-list-detail.component.html | 12 +- .../reading-list-detail.component.ts | 11 - .../reading-lists.component.html | 2 +- .../series-detail.component.html | 2 +- .../series-detail/series-detail.component.ts | 14 - .../app/shared/_services/utility.service.ts | 22 +- .../side-nav/side-nav.component.html | 6 +- .../side-nav/side-nav.component.ts | 14 +- .../library-settings-modal.component.ts | 4 +- .../volume-detail.component.html | 2 +- .../volume-detail/volume-detail.component.ts | 27 +- UI/Web/src/theme/themes/dark.scss | 4 + build.sh | 4 +- 63 files changed, 659 insertions(+), 567 deletions(-) delete mode 100644 UI/Web/src/app/admin/manage-logs/manage-logs.component.html delete mode 100644 UI/Web/src/app/admin/manage-logs/manage-logs.component.scss delete mode 100644 UI/Web/src/app/admin/manage-logs/manage-logs.component.ts diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index 20e10e548..9e7fc3a02 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -9,10 +9,10 @@ - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/API/API.csproj b/API/API.csproj index 1ddb37d7f..f9a889d74 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -51,7 +51,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -66,7 +66,7 @@ - + @@ -78,7 +78,7 @@ - + @@ -87,20 +87,20 @@ - + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + - + @@ -111,17 +111,16 @@ - - - - - + + + + @@ -139,6 +138,7 @@ + @@ -188,7 +188,6 @@ - Always diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs index 6cd911700..fae674ded 100644 --- a/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs +++ b/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs @@ -4,14 +4,18 @@ using API.DTOs.Scrobbling; namespace API.DTOs.KavitaPlus.ExternalMetadata; #nullable enable +/// +/// Represents a request to match some series from Kavita to an external id which K+ uses. +/// internal sealed record MatchSeriesRequestDto { - public string SeriesName { get; set; } - public ICollection AlternativeNames { get; set; } + public required string SeriesName { get; set; } + public ICollection AlternativeNames { get; set; } = []; public int Year { get; set; } = 0; - public string Query { get; set; } + public string? Query { get; set; } public int? AniListId { get; set; } public long? MalId { get; set; } public string? HardcoverId { get; set; } + public int? CbrId { get; set; } public PlusMediaFormat Format { get; set; } } diff --git a/API/Middleware/SecurityMiddleware.cs b/API/Middleware/SecurityMiddleware.cs index 61ca1c75d..67cb42d0c 100644 --- a/API/Middleware/SecurityMiddleware.cs +++ b/API/Middleware/SecurityMiddleware.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Net; using System.Text.Json; using System.Threading.Tasks; @@ -26,7 +27,7 @@ public class SecurityEventMiddleware(RequestDelegate next) } catch (KavitaUnauthenticatedUserException ex) { - var ipAddress = context.Connection.RemoteIpAddress?.ToString(); + var ipAddress = context.Request.Headers["X-Forwarded-For"].FirstOrDefault() ?? context.Connection.RemoteIpAddress?.ToString(); var requestMethod = context.Request.Method; var requestPath = context.Request.Path; var userAgent = context.Request.Headers.UserAgent; diff --git a/API/Program.cs b/API/Program.cs index 852844f2f..011a7de2a 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.IO.Abstractions; using System.Linq; using System.Security.Cryptography; @@ -48,15 +49,13 @@ public class Program var directoryService = new DirectoryService(null!, new FileSystem()); + + // Check if this is the first time running and if so, rename appsettings-init.json to appsettings.json + HandleFirstRunConfiguration(); + + // Before anything, check if JWT has been generated properly or if user still has default - if (!Configuration.CheckIfJwtTokenSet() && - Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") != Environments.Development) - { - Log.Logger.Information("Generating JWT TokenKey for encrypting user sessions..."); - var rBytes = new byte[256]; - RandomNumberGenerator.Create().GetBytes(rBytes); - Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty); - } + EnsureJwtTokenKey(); try { @@ -70,6 +69,7 @@ public class Program { var logger = services.GetRequiredService>(); var context = services.GetRequiredService(); + var pendingMigrations = await context.Database.GetPendingMigrationsAsync(); var isDbCreated = await context.Database.CanConnectAsync(); if (isDbCreated && pendingMigrations.Any()) @@ -157,6 +157,26 @@ public class Program } } + private static void EnsureJwtTokenKey() + { + if (Configuration.CheckIfJwtTokenSet() || Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development) return; + + Log.Logger.Information("Generating JWT TokenKey for encrypting user sessions..."); + var rBytes = new byte[256]; + RandomNumberGenerator.Create().GetBytes(rBytes); + Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty); + } + + private static void HandleFirstRunConfiguration() + { + var firstRunConfigFilePath = Path.Join(Directory.GetCurrentDirectory(), "config/appsettings-init.json"); + if (File.Exists(firstRunConfigFilePath) && + !File.Exists(Path.Join(Directory.GetCurrentDirectory(), "config/appsettings.json"))) + { + File.Move(firstRunConfigFilePath, Path.Join(Directory.GetCurrentDirectory(), "config/appsettings.json")); + } + } + private static async Task GetMigrationDirectory(DataContext context, IDirectoryService directoryService) { string? currentVersion = null; diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index a0c88b16d..a1e3750dd 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -226,7 +226,7 @@ public class ExternalMetadataService : IExternalMetadataService AlternativeNames = altNames.Where(s => !string.IsNullOrEmpty(s)).ToList(), Year = series.Metadata.ReleaseYear, AniListId = potentialAnilistId ?? ScrobblingService.GetAniListId(series), - MalId = potentialMalId ?? ScrobblingService.GetMalId(series), + MalId = potentialMalId ?? ScrobblingService.GetMalId(series) }; var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; @@ -792,7 +792,7 @@ public class ExternalMetadataService : IExternalMetadataService var characters = externalCharacters .Select(w => new PersonDto() { - Name = w.Name, + Name = w.Name.Trim(), AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListCharacterWebsite), Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), }) @@ -873,7 +873,7 @@ public class ExternalMetadataService : IExternalMetadataService var artists = upstreamArtists .Select(w => new PersonDto() { - Name = w.Name, + Name = w.Name.Trim(), AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListStaffWebsite), Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), }) @@ -929,7 +929,7 @@ public class ExternalMetadataService : IExternalMetadataService var writers = upstreamWriters .Select(w => new PersonDto() { - Name = w.Name, + Name = w.Name.Trim(), AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListStaffWebsite), Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), }) @@ -1353,7 +1353,7 @@ public class ExternalMetadataService : IExternalMetadataService var people = staff! .Select(w => new PersonDto() { - Name = w, + Name = w.Trim(), }) .Concat(chapter.People .Where(p => p.Role == role) diff --git a/API/Services/Tasks/Metadata/CoverDbService.cs b/API/Services/Tasks/Metadata/CoverDbService.cs index d58b225a5..59f01de55 100644 --- a/API/Services/Tasks/Metadata/CoverDbService.cs +++ b/API/Services/Tasks/Metadata/CoverDbService.cs @@ -501,7 +501,7 @@ public class CoverDbService : ICoverDbService else { _directoryService.DeleteFiles([tempFullPath]); - person.CoverImage = Path.GetFileName(existingPath); + return; } } else @@ -651,6 +651,7 @@ public class CoverDbService : ICoverDbService else { _directoryService.DeleteFiles([tempFullPath]); + return; } chapter.CoverImage = finalFileName; diff --git a/API/Services/Tasks/Scanner/LibraryWatcher.cs b/API/Services/Tasks/Scanner/LibraryWatcher.cs index d2e6437a3..fec0304a8 100644 --- a/API/Services/Tasks/Scanner/LibraryWatcher.cs +++ b/API/Services/Tasks/Scanner/LibraryWatcher.cs @@ -310,7 +310,7 @@ public class LibraryWatcher : ILibraryWatcher if (rootFolder.Count == 0) return string.Empty; // Select the first folder and join with library folder, this should give us the folder to scan. - return Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder[rootFolder.Count - 1])); + return Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder[^1])); } diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index 123b610ff..4ccf79abb 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -52,6 +52,7 @@ public interface IVersionUpdaterService Task PushUpdate(UpdateNotificationDto update); Task> GetAllReleases(int count = 0); Task GetNumberOfReleasesBehind(bool stableOnly = false); + void BustGithubCache(); } @@ -384,7 +385,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration) { var cachedData = await File.ReadAllTextAsync(_cacheLatestReleaseFilePath); - return System.Text.Json.JsonSerializer.Deserialize(cachedData); + return JsonSerializer.Deserialize(cachedData); } return null; @@ -407,7 +408,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService { try { - var json = System.Text.Json.JsonSerializer.Serialize(update, JsonOptions); + var json = JsonSerializer.Serialize(update, JsonOptions); await File.WriteAllTextAsync(_cacheLatestReleaseFilePath, json); } catch (Exception ex) @@ -446,6 +447,21 @@ public partial class VersionUpdaterService : IVersionUpdaterService .Count(u => u.IsReleaseNewer); } + /// + /// Clears the Github cache + /// + public void BustGithubCache() + { + try + { + File.Delete(_cacheFilePath); + File.Delete(_cacheLatestReleaseFilePath); + } catch (Exception ex) + { + _logger.LogError(ex, "Failed to clear Github cache"); + } + } + private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update) { if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return null; diff --git a/API/Startup.cs b/API/Startup.cs index 34af22154..cb32d1742 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -55,6 +55,9 @@ public class Startup { _config = config; _env = env; + + // Disable Hangfire Automatic Retry + GlobalJobFilters.Filters.Add(new AutomaticRetryAttribute { Attempts = 0 }); } // This method gets called by the runtime. Use this method to add services to the container. @@ -223,7 +226,7 @@ public class Startup // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJobs, IWebHostEnvironment env, IHostApplicationLifetime applicationLifetime, IServiceProvider serviceProvider, ICacheService cacheService, - IDirectoryService directoryService, IUnitOfWork unitOfWork, IBackupService backupService, IImageService imageService) + IDirectoryService directoryService, IUnitOfWork unitOfWork, IBackupService backupService, IImageService imageService, IVersionUpdaterService versionService) { var logger = serviceProvider.GetRequiredService>(); @@ -235,9 +238,10 @@ public class Startup // Apply all migrations on startup var dataContext = serviceProvider.GetRequiredService(); - logger.LogInformation("Running Migrations"); + #region Migrations + // v0.7.9 await MigrateUserLibrarySideNavStream.Migrate(unitOfWork, dataContext, logger); @@ -289,13 +293,23 @@ public class Startup await ManualMigrateScrobbleSpecials.Migrate(dataContext, logger); await ManualMigrateScrobbleEventGen.Migrate(dataContext, logger); + #endregion + // Update the version in the DB after all migrations are run var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); + var isVersionDifferent = installVersion.Value != BuildInfo.Version.ToString(); installVersion.Value = BuildInfo.Version.ToString(); unitOfWork.SettingsRepository.Update(installVersion); await unitOfWork.CommitAsync(); logger.LogInformation("Running Migrations - complete"); + + if (isVersionDifferent) + { + // Clear the Github cache so update stuff shows correctly + versionService.BustGithubCache(); + } + }).GetAwaiter() .GetResult(); } diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 029166254..9e10f5ccf 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -14,7 +14,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 0fef35b0e..61fee39ec 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -7,12 +7,13 @@ import {Library} from '../_models/library/library'; import {ReadingList} from '../_models/reading-list'; import {Series} from '../_models/series'; import {Volume} from '../_models/volume'; -import {AccountService} from './account.service'; +import {AccountService, Role} from './account.service'; import {DeviceService} from './device.service'; import {SideNavStream} from "../_models/sidenav/sidenav-stream"; import {SmartFilter} from "../_models/metadata/v2/smart-filter"; import {translate} from "@jsverse/transloco"; import {Person} from "../_models/metadata/person"; +import {User} from '../_models/user'; export enum Action { Submenu = -1, @@ -106,7 +107,7 @@ export enum Action { Promote = 24, UnPromote = 25, /** - * Invoke a refresh covers as false to generate colorscapes + * Invoke refresh covers as false to generate colorscapes */ GenerateColorScape = 26, /** @@ -126,14 +127,21 @@ export enum Action { /** * Callback for an action */ -export type ActionCallback = (action: ActionItem, data: T) => void; -export type ActionAllowedCallback = (action: ActionItem) => boolean; +export type ActionCallback = (action: ActionItem, entity: T) => void; +export type ActionShouldRenderFunc = (action: ActionItem, entity: T, user: User) => boolean; export interface ActionItem { title: string; description: string; action: Action; callback: ActionCallback; + /** + * Roles required to be present for ActionItem to show. If empty, assumes anyone can see. At least one needs to apply. + */ + requiredRoles: Role[]; + /** + * @deprecated Use required Roles instead + */ requiresAdmin: boolean; children: Array>; /** @@ -149,94 +157,98 @@ export interface ActionItem { * Extra data that needs to be sent back from the card item. Used mainly for dynamicList. This will be the item from dyanamicList return */ _extra?: {title: string, data: any}; + /** + * Will call on each action to determine if it should show for the appropriate entity based on state and user + */ + shouldRender: ActionShouldRenderFunc; } +/** + * Entities that can be actioned upon + */ +export type ActionableEntity = Volume | Series | Chapter | ReadingList | UserCollection | Person | Library | SideNavStream | SmartFilter | null; + @Injectable({ providedIn: 'root', }) export class ActionFactoryService { - libraryActions: Array> = []; - - seriesActions: Array> = []; - - volumeActions: Array> = []; - - chapterActions: Array> = []; - - collectionTagActions: Array> = []; - - readingListActions: Array> = []; - - bookmarkActions: Array> = []; - + private libraryActions: Array> = []; + private seriesActions: Array> = []; + private volumeActions: Array> = []; + private chapterActions: Array> = []; + private collectionTagActions: Array> = []; + private readingListActions: Array> = []; + private bookmarkActions: Array> = []; private personActions: Array> = []; - - sideNavStreamActions: Array> = []; - smartFilterActions: Array> = []; - - sideNavHomeActions: Array> = []; - - isAdmin = false; - + private sideNavStreamActions: Array> = []; + private smartFilterActions: Array> = []; + private sideNavHomeActions: Array> = []; constructor(private accountService: AccountService, private deviceService: DeviceService) { - this.accountService.currentUser$.subscribe((user) => { - if (user) { - this.isAdmin = this.accountService.hasAdminRole(user); - } else { - this._resetActions(); - return; // If user is logged out, we don't need to do anything - } - + this.accountService.currentUser$.subscribe((_) => { this._resetActions(); }); } - getLibraryActions(callback: ActionCallback) { - return this.applyCallbackToList(this.libraryActions, callback); + getLibraryActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.libraryActions, callback, shouldRenderFunc) as ActionItem[]; } - getSeriesActions(callback: ActionCallback) { - return this.applyCallbackToList(this.seriesActions, callback); + getSeriesActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList(this.seriesActions, callback, shouldRenderFunc); } - getSideNavStreamActions(callback: ActionCallback) { - return this.applyCallbackToList(this.sideNavStreamActions, callback); + getSideNavStreamActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.sideNavStreamActions, callback, shouldRenderFunc); } - getSmartFilterActions(callback: ActionCallback) { - return this.applyCallbackToList(this.smartFilterActions, callback); + getSmartFilterActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.smartFilterActions, callback, shouldRenderFunc); } - getVolumeActions(callback: ActionCallback) { - return this.applyCallbackToList(this.volumeActions, callback); + getVolumeActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList(this.volumeActions, callback, shouldRenderFunc); } - getChapterActions(callback: ActionCallback) { - return this.applyCallbackToList(this.chapterActions, callback); + getChapterActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList(this.chapterActions, callback, shouldRenderFunc); } - getCollectionTagActions(callback: ActionCallback) { - return this.applyCallbackToList(this.collectionTagActions, callback); + getCollectionTagActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.collectionTagActions, callback, shouldRenderFunc); } - getReadingListActions(callback: ActionCallback) { - return this.applyCallbackToList(this.readingListActions, callback); + getReadingListActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.readingListActions, callback, shouldRenderFunc); } - getBookmarkActions(callback: ActionCallback) { - return this.applyCallbackToList(this.bookmarkActions, callback); + getBookmarkActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.bookmarkActions, callback, shouldRenderFunc); } - getPersonActions(callback: ActionCallback) { - return this.applyCallbackToList(this.personActions, callback); + getPersonActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.personActions, callback, shouldRenderFunc); } - getSideNavHomeActions(callback: ActionCallback) { - return this.applyCallbackToList(this.sideNavHomeActions, callback); + getSideNavHomeActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.sideNavHomeActions, callback, shouldRenderFunc); } - dummyCallback(action: ActionItem, data: any) {} + dummyCallback(action: ActionItem, entity: any) {} + dummyShouldRender(action: ActionItem, entity: any, user: User) {return true;} + basicReadRender(action: ActionItem, entity: any, user: User) { + if (entity === null || entity === undefined) return true; + if (!entity.hasOwnProperty('pagesRead') && !entity.hasOwnProperty('pages')) return true; + + switch (action.action) { + case(Action.MarkAsRead): + return entity.pagesRead < entity.pages; + case(Action.MarkAsUnread): + return entity.pagesRead !== 0; + default: + return true; + } + } filterSendToAction(actions: Array>, chapter: Chapter) { // if (chapter.files.filter(f => f.format === MangaFormat.EPUB || f.format === MangaFormat.PDF).length !== chapter.files.length) { @@ -279,7 +291,7 @@ export class ActionFactoryService { return tasks.filter(t => !blacklist.includes(t.action)); } - getBulkLibraryActions(callback: ActionCallback) { + getBulkLibraryActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { // Scan is currently not supported due to the backend not being able to handle it yet const actions = this.flattenActions(this.libraryActions).filter(a => { @@ -293,11 +305,13 @@ export class ActionFactoryService { dynamicList: undefined, action: Action.CopySettings, callback: this.dummyCallback, + shouldRender: shouldRenderFunc, children: [], + requiredRoles: [Role.Admin], requiresAdmin: true, title: 'copy-settings' }) - return this.applyCallbackToList(actions, callback); + return this.applyCallbackToList(actions, callback, shouldRenderFunc) as ActionItem[]; } flattenActions(actions: Array>): Array> { @@ -323,7 +337,9 @@ export class ActionFactoryService { title: 'scan-library', description: 'scan-library-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -331,14 +347,18 @@ export class ActionFactoryService { title: 'others', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [ { action: Action.RefreshMetadata, title: 'refresh-covers', description: 'refresh-covers-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -346,7 +366,9 @@ export class ActionFactoryService { title: 'generate-colorscape', description: 'generate-colorscape-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -354,7 +376,9 @@ export class ActionFactoryService { title: 'analyze-files', description: 'analyze-files-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -362,7 +386,9 @@ export class ActionFactoryService { title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, ], @@ -372,7 +398,9 @@ export class ActionFactoryService { title: 'settings', description: 'settings-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, ]; @@ -383,7 +411,9 @@ export class ActionFactoryService { title: 'edit', description: 'edit-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -391,7 +421,9 @@ export class ActionFactoryService { title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], class: 'danger', children: [], }, @@ -400,7 +432,9 @@ export class ActionFactoryService { title: 'promote', description: 'promote-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -408,7 +442,9 @@ export class ActionFactoryService { title: 'unpromote', description: 'unpromote-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -419,7 +455,9 @@ export class ActionFactoryService { title: 'mark-as-read', description: 'mark-as-read-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -427,7 +465,9 @@ export class ActionFactoryService { title: 'mark-as-unread', description: 'mark-as-unread-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -435,7 +475,9 @@ export class ActionFactoryService { title: 'scan-series', description: 'scan-series-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -443,14 +485,18 @@ export class ActionFactoryService { title: 'add-to', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.AddToWantToReadList, title: 'add-to-want-to-read', description: 'add-to-want-to-read-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -458,7 +504,9 @@ export class ActionFactoryService { title: 'remove-from-want-to-read', description: 'remove-to-want-to-read-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -466,7 +514,9 @@ export class ActionFactoryService { title: 'add-to-reading-list', description: 'add-to-reading-list-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -474,26 +524,11 @@ export class ActionFactoryService { title: 'add-to-collection', description: 'add-to-collection-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, - - // { - // action: Action.AddToScrobbleHold, - // title: 'add-to-scrobble-hold', - // description: 'add-to-scrobble-hold-tooltip', - // callback: this.dummyCallback, - // requiresAdmin: true, - // children: [], - // }, - // { - // action: Action.RemoveFromScrobbleHold, - // title: 'remove-from-scrobble-hold', - // description: 'remove-from-scrobble-hold-tooltip', - // callback: this.dummyCallback, - // requiresAdmin: true, - // children: [], - // }, ], }, { @@ -501,14 +536,18 @@ export class ActionFactoryService { title: 'send-to', description: 'send-to-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.SendTo, title: '', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { return {'title': d.name, 'data': d}; }), shareReplay())), @@ -521,14 +560,18 @@ export class ActionFactoryService { title: 'others', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [], children: [ { action: Action.RefreshMetadata, title: 'refresh-covers', description: 'refresh-covers-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -536,7 +579,9 @@ export class ActionFactoryService { title: 'generate-colorscape', description: 'generate-colorscape-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -544,7 +589,9 @@ export class ActionFactoryService { title: 'analyze-files', description: 'analyze-files-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -552,7 +599,9 @@ export class ActionFactoryService { title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], class: 'danger', children: [], }, @@ -563,7 +612,9 @@ export class ActionFactoryService { title: 'match', description: 'match-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -571,7 +622,9 @@ export class ActionFactoryService { title: 'download', description: 'download-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [Role.Download], children: [], }, { @@ -579,7 +632,9 @@ export class ActionFactoryService { title: 'edit', description: 'edit-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, ]; @@ -590,7 +645,9 @@ export class ActionFactoryService { title: 'read-incognito', description: 'read-incognito-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -598,7 +655,9 @@ export class ActionFactoryService { title: 'mark-as-read', description: 'mark-as-read-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -606,7 +665,9 @@ export class ActionFactoryService { title: 'mark-as-unread', description: 'mark-as-unread-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -614,14 +675,18 @@ export class ActionFactoryService { title: 'add-to', description: '=', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.AddToReadingList, title: 'add-to-reading-list', description: 'add-to-reading-list-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], } ] @@ -631,14 +696,18 @@ export class ActionFactoryService { title: 'send-to', description: 'send-to-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.SendTo, title: '', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { return {'title': d.name, 'data': d}; }), shareReplay())), @@ -651,14 +720,18 @@ export class ActionFactoryService { title: 'others', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.Delete, title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -666,7 +739,9 @@ export class ActionFactoryService { title: 'download', description: 'download-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ] @@ -676,7 +751,9 @@ export class ActionFactoryService { title: 'details', description: 'edit-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -687,7 +764,9 @@ export class ActionFactoryService { title: 'read-incognito', description: 'read-incognito-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -695,7 +774,9 @@ export class ActionFactoryService { title: 'mark-as-read', description: 'mark-as-read-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -703,7 +784,9 @@ export class ActionFactoryService { title: 'mark-as-unread', description: 'mark-as-unread-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -711,14 +794,18 @@ export class ActionFactoryService { title: 'add-to', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.AddToReadingList, title: 'add-to-reading-list', description: 'add-to-reading-list-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], } ] @@ -728,14 +815,18 @@ export class ActionFactoryService { title: 'send-to', description: 'send-to-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.SendTo, title: '', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { return {'title': d.name, 'data': d}; }), shareReplay())), @@ -749,14 +840,18 @@ export class ActionFactoryService { title: 'others', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.Delete, title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -764,7 +859,9 @@ export class ActionFactoryService { title: 'download', description: 'download-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [Role.Download], children: [], }, ] @@ -774,7 +871,9 @@ export class ActionFactoryService { title: 'edit', description: 'edit-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -785,7 +884,9 @@ export class ActionFactoryService { title: 'edit', description: 'edit-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -793,7 +894,9 @@ export class ActionFactoryService { title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], class: 'danger', children: [], }, @@ -802,7 +905,9 @@ export class ActionFactoryService { title: 'promote', description: 'promote-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -810,7 +915,9 @@ export class ActionFactoryService { title: 'unpromote', description: 'unpromote-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -821,7 +928,9 @@ export class ActionFactoryService { title: 'edit', description: 'edit-person-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -829,7 +938,9 @@ export class ActionFactoryService { title: 'merge', description: 'merge-person-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], } ]; @@ -840,7 +951,9 @@ export class ActionFactoryService { title: 'view-series', description: 'view-series-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -848,7 +961,9 @@ export class ActionFactoryService { title: 'download', description: 'download-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -856,8 +971,10 @@ export class ActionFactoryService { title: 'clear', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, class: 'danger', requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -868,7 +985,9 @@ export class ActionFactoryService { title: 'mark-visible', description: 'mark-visible-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -876,7 +995,9 @@ export class ActionFactoryService { title: 'mark-invisible', description: 'mark-invisible-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -887,7 +1008,9 @@ export class ActionFactoryService { title: 'rename', description: 'rename-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -895,7 +1018,9 @@ export class ActionFactoryService { title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -906,7 +1031,9 @@ export class ActionFactoryService { title: 'reorder', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], } ] @@ -914,21 +1041,24 @@ export class ActionFactoryService { } - private applyCallback(action: ActionItem, callback: (action: ActionItem, data: any) => void) { + private applyCallback(action: ActionItem, callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc) { action.callback = callback; + action.shouldRender = shouldRenderFunc; if (action.children === null || action.children?.length === 0) return; action.children?.forEach((childAction) => { - this.applyCallback(childAction, callback); + this.applyCallback(childAction, callback, shouldRenderFunc); }); } - public applyCallbackToList(list: Array>, callback: (action: ActionItem, data: any) => void): Array> { + public applyCallbackToList(list: Array>, + callback: ActionCallback, + shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender): Array> { const actions = list.map((a) => { return { ...a }; }); - actions.forEach((action) => this.applyCallback(action, callback)); + actions.forEach((action) => this.applyCallback(action, callback, shouldRenderFunc)); return actions; } diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index fd24bd9ff..37826b0e2 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -473,8 +473,7 @@ export class ActionService { } async deleteMultipleVolumes(volumes: Array, callback?: BooleanActionCallback) { - // TODO: Change translation key back to "toasts.confirm-delete-multiple-volumes" - if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters', {count: volumes.length}))) return; + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-volumes', {count: volumes.length}))) return; this.volumeService.deleteMultipleVolumes(volumes.map(v => v.id)).subscribe((success) => { if (callback) { diff --git a/UI/Web/src/app/_services/statistics.service.ts b/UI/Web/src/app/_services/statistics.service.ts index f13b29c87..cf80765f2 100644 --- a/UI/Web/src/app/_services/statistics.service.ts +++ b/UI/Web/src/app/_services/statistics.service.ts @@ -1,20 +1,19 @@ -import { HttpClient } from '@angular/common/http'; +import {HttpClient, HttpParams} from '@angular/common/http'; import {Inject, inject, Injectable} from '@angular/core'; -import { environment } from 'src/environments/environment'; -import { UserReadStatistics } from '../statistics/_models/user-read-statistics'; -import { PublicationStatusPipe } from '../_pipes/publication-status.pipe'; -import {asyncScheduler, finalize, map, tap} from 'rxjs'; -import { MangaFormatPipe } from '../_pipes/manga-format.pipe'; -import { FileExtensionBreakdown } from '../statistics/_models/file-breakdown'; -import { TopUserRead } from '../statistics/_models/top-reads'; -import { ReadHistoryEvent } from '../statistics/_models/read-history-event'; -import { ServerStatistics } from '../statistics/_models/server-statistics'; -import { StatCount } from '../statistics/_models/stat-count'; -import { PublicationStatus } from '../_models/metadata/publication-status'; -import { MangaFormat } from '../_models/manga-format'; -import { TextResonse } from '../_types/text-response'; +import {environment} from 'src/environments/environment'; +import {UserReadStatistics} from '../statistics/_models/user-read-statistics'; +import {PublicationStatusPipe} from '../_pipes/publication-status.pipe'; +import {asyncScheduler, map} from 'rxjs'; +import {MangaFormatPipe} from '../_pipes/manga-format.pipe'; +import {FileExtensionBreakdown} from '../statistics/_models/file-breakdown'; +import {TopUserRead} from '../statistics/_models/top-reads'; +import {ReadHistoryEvent} from '../statistics/_models/read-history-event'; +import {ServerStatistics} from '../statistics/_models/server-statistics'; +import {StatCount} from '../statistics/_models/stat-count'; +import {PublicationStatus} from '../_models/metadata/publication-status'; +import {MangaFormat} from '../_models/manga-format'; +import {TextResonse} from '../_types/text-response'; import {TranslocoService} from "@jsverse/transloco"; -import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown"; import {throttleTime} from "rxjs/operators"; import {DEBOUNCE_TIME} from "../shared/_services/download.service"; import {download} from "../shared/_models/download"; @@ -44,11 +43,14 @@ export class StatisticsService { constructor(private httpClient: HttpClient, @Inject(SAVER) private save: Saver) { } getUserStatistics(userId: number, libraryIds: Array = []) { - // TODO: Convert to httpParams object - let url = 'stats/user/' + userId + '/read'; - if (libraryIds.length > 0) url += '?libraryIds=' + libraryIds.join(','); + const url = `${this.baseUrl}stats/user/${userId}/read`; - return this.httpClient.get(this.baseUrl + url); + let params = new HttpParams(); + if (libraryIds.length > 0) { + params = params.set('libraryIds', libraryIds.join(',')); + } + + return this.httpClient.get(url, { params }); } getServerStatistics() { @@ -59,7 +61,7 @@ export class StatisticsService { return this.httpClient.get[]>(this.baseUrl + 'stats/server/count/year').pipe( map(spreads => spreads.map(spread => { return {name: spread.value + '', value: spread.count}; - }))); + }))); } getTopYears() { diff --git a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html index 067dc5fb2..caf8bf683 100644 --- a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html +++ b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html @@ -1,7 +1,9 @@
  • /// /// - /// + /// This is not in use /// [HttpPost("all-v2")] public async Task>> GetAllSeriesV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams, @@ -321,8 +321,6 @@ public class SeriesController : BaseApiController await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto, context); // Apply progress/rating information (I can't work out how to do this in initial query) - if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series")); - await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 944ea987b..e5cfb626a 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -128,6 +128,7 @@ public class UsersController : BaseApiController existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize; existingPreferences.NoTransitions = preferencesDto.NoTransitions; existingPreferences.SwipeToPaginate = preferencesDto.SwipeToPaginate; + existingPreferences.AllowAutomaticWebtoonReaderDetection = preferencesDto.AllowAutomaticWebtoonReaderDetection; existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships; existingPreferences.ShareReviews = preferencesDto.ShareReviews; diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 6b8cdc243..8e8576069 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -102,11 +102,22 @@ export class AccountService { return true; } + /** + * If the user has any role in the restricted roles array or is an Admin + * @param user + * @param roles + * @param restrictedRoles + */ hasAnyRole(user: User, roles: Array, restrictedRoles: Array = []) { if (!user || !user.roles) { return false; } + // If the user is an admin, they have the role + if (this.hasAdminRole(user)) { + return true; + } + // If restricted roles are provided and the user has any of them, deny access if (restrictedRoles.length > 0 && restrictedRoles.some(role => user.roles.includes(role))) { return false; diff --git a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html index caf8bf683..7573c554a 100644 --- a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html +++ b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html @@ -15,7 +15,7 @@
    @for (action of currentItems; track action.title) { - @if (willRenderAction(action)) { + @if (willRenderAction(action, user!)) {
    - + + @if (layoutMode !== BookPageLayoutMode.Default) { + @let vp = getVirtualPage();
    {{t('page-label')}}
    -
    -
    {{vp[0]}}
    - +
    -
    {{vp[1]}}
    +
    {{vp[1]}}
    -
    - + }
    {{t('pagination-header')}}
    diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss index dcfa9ddcd..8f45302c3 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss @@ -277,9 +277,9 @@ $action-bar-height: 38px; } .virt-pagination-cont { - padding-bottom: 5px; - margin-bottom: 5px; - box-shadow: var(--drawer-pagination-horizontal-rule); + padding-bottom: 5px; + margin-bottom: 5px; + box-shadow: var(--drawer-pagination-horizontal-rule); } .bottom-bar { diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts index 6abd619f8..002d769e8 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts @@ -63,6 +63,7 @@ import { PersonalToCEvent } from "../personal-table-of-contents/personal-table-of-contents.component"; import {translate, TranslocoDirective} from "@jsverse/transloco"; +import {ConfirmService} from "../../../shared/confirm.service"; enum TabID { @@ -133,6 +134,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { private readonly utilityService = inject(UtilityService); private readonly libraryService = inject(LibraryService); private readonly themeService = inject(ThemeService); + private readonly confirmService = inject(ConfirmService); private readonly cdRef = inject(ChangeDetectorRef); protected readonly BookPageLayoutMode = BookPageLayoutMode; @@ -730,7 +732,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } @HostListener('window:keydown', ['$event']) - handleKeyPress(event: KeyboardEvent) { + async handleKeyPress(event: KeyboardEvent) { const activeElement = document.activeElement as HTMLElement; const isInputFocused = activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA'; if (isInputFocused) return; @@ -748,7 +750,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { event.stopPropagation(); event.preventDefault(); } else if (event.key === KEY_CODES.G) { - this.goToPage(); + await this.goToPage(); } else if (event.key === KEY_CODES.F) { this.toggleFullscreen() } @@ -905,33 +907,35 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } - promptForPage() { - const question = translate('book-reader.go-to-page-prompt', {totalPages: this.maxPages - 1}); - const goToPageNum = window.prompt(question, ''); + async promptForPage() { + const promptConfig = {...this.confirmService.defaultPrompt}; + // Pages are called sections in the UI, manga reader uses the go-to-page string so we use a different one here + promptConfig.header = translate('book-reader.go-to-section'); + promptConfig.content = translate('book-reader.go-to-section-prompt', {totalSections: this.maxPages - 1}); + + const goToPageNum = await this.confirmService.prompt(undefined, promptConfig); + if (goToPageNum === null || goToPageNum.trim().length === 0) { return null; } return goToPageNum; } - goToPage(pageNum?: number) { + async goToPage(pageNum?: number) { let page = pageNum; if (pageNum === null || pageNum === undefined) { - const goToPageNum = this.promptForPage(); + const goToPageNum = await this.promptForPage(); if (goToPageNum === null) { return; } + page = parseInt(goToPageNum.trim(), 10); } if (page === undefined || this.pageNum === page) { return; } - if (page > this.maxPages) { - page = this.maxPages; + if (page > this.maxPages - 1) { + page = this.maxPages - 1; } else if (page < 0) { page = 0; } - if (!(page === 0 || page === this.maxPages - 1)) { - page -= 1; - } - this.pageNum = page; this.loadPage(); } diff --git a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.html b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.html index 585f3af42..ead8b3540 100644 --- a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.html +++ b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.html @@ -17,12 +17,12 @@ } @else { @for (chapterGroup of chapters; track chapterGroup.title + chapterGroup.children.length) {
      -
    • +
    • {{chapterGroup.title}}
    • @for(chapter of chapterGroup.children; track chapter.title + chapter.part) { diff --git a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.scss b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.scss index e556f0e78..ca8e747f4 100644 --- a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.scss +++ b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.scss @@ -3,9 +3,10 @@ &.active { font-weight: bold; + color: var(--primary-color); } } .chapter-title { padding-inline-start: 1rem; -} \ No newline at end of file +} diff --git a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts index cb6417874..ce3a180ed 100644 --- a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts +++ b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts @@ -31,9 +31,8 @@ export class TableOfContentsComponent implements OnChanges { @Output() loadChapter: EventEmitter<{pageNum: number, part: string}> = new EventEmitter(); ngOnChanges(changes: SimpleChanges) { - console.log('Current Page: ', this.pageNum, this.currentPageAnchor); + //console.log('Current Page: ', this.pageNum, this.currentPageAnchor); this.cdRef.markForCheck(); - } cleanIdSelector(id: string) { @@ -47,4 +46,30 @@ export class TableOfContentsComponent implements OnChanges { loadChapterPage(pageNum: number, part: string) { this.loadChapter.emit({pageNum, part}); } + + isChapterSelected(chapterGroup: BookChapterItem) { + if (chapterGroup.page === this.pageNum) { + return true; + } + + const idx = this.chapters.indexOf(chapterGroup); + if (idx < 0) { + return false; // should never happen + } + + const nextIdx = idx + 1; + // Last chapter + if (nextIdx >= this.chapters.length) { + return chapterGroup.page < this.pageNum; + } + + // Passed chapter, and next chapter has not been reached + const next = this.chapters[nextIdx]; + return chapterGroup.page < this.pageNum && next.page > this.pageNum; + } + + isAnchorSelected(chapter: BookChapterItem) { + return this.cleanIdSelector(chapter.part) === this.currentPageAnchor + } + } diff --git a/UI/Web/src/app/cards/card-item/card-item.component.html b/UI/Web/src/app/cards/card-item/card-item.component.html index dc89c563c..c0a4c0890 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.html +++ b/UI/Web/src/app/cards/card-item/card-item.component.html @@ -94,7 +94,7 @@ @if (actions && actions.length > 0) { - + }
    diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index 6bdbcaf18..37de9ca13 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -26,7 +26,7 @@ import {Series} from 'src/app/_models/series'; import {User} from 'src/app/_models/user'; import {Volume} from 'src/app/_models/volume'; import {AccountService} from 'src/app/_services/account.service'; -import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service'; +import {Action, ActionableEntity, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service'; import {ImageService} from 'src/app/_services/image.service'; import {LibraryService} from 'src/app/_services/library.service'; import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service'; @@ -118,6 +118,10 @@ export class CardItemComponent implements OnInit { * This is the entity we are representing. It will be returned if an action is executed. */ @Input({required: true}) entity!: CardEntity; + /** + * An optional entity to be used in the action callback + */ + @Input() actionEntity: ActionableEntity | null = null; /** * If the entity is selected or not. */ diff --git a/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html b/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html index 09923b239..599d1c156 100644 --- a/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html +++ b/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html @@ -14,7 +14,7 @@ [trackByIdentity]="trackByIdentity" > - { if (!user) return; - this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this)) + this.collectionTagActions = this.actionFactoryService.getCollectionTagActions( + this.handleCollectionActionCallback.bind(this), this.shouldRenderCollection.bind(this)) .filter(action => this.collectionService.actionListFilter(action, user)); this.cdRef.markForCheck(); }); } + shouldRenderCollection(action: ActionItem, entity: UserCollection, user: User) { + switch (action.action) { + case Action.Promote: + return !entity.promoted; + case Action.UnPromote: + return entity.promoted; + default: + return true; + } + } + loadCollection(item: UserCollection) { this.router.navigate(['collections', item.id]); } diff --git a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html index 927316f99..1fad4b6e8 100644 --- a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html +++ b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html @@ -11,7 +11,7 @@ } -
    {{t('item-count', {num: series.length})}}
    +
    {{t('item-count', {num: series.length})}}
    } diff --git a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts index ceb539718..d99626b64 100644 --- a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts +++ b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts @@ -207,7 +207,8 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked { this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => { if (!user) return; this.user = user; - this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this)) + this.collectionTagActions = this.actionFactoryService.getCollectionTagActions( + this.handleCollectionActionCallback.bind(this), this.shouldRenderCollection.bind(this)) .filter(action => this.collectionService.actionListFilter(action, user)); this.cdRef.markForCheck(); }); @@ -225,6 +226,17 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked { }); } + shouldRenderCollection(action: ActionItem, entity: UserCollection, user: User) { + switch (action.action) { + case Action.Promote: + return !entity.promoted; + case Action.UnPromote: + return entity.promoted; + default: + return true; + } + } + ngAfterContentChecked(): void { this.scrollService.setScrollContainer(this.scrollingBlock); } 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 595ae6079..a7bbe2d90 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 @@ -70,6 +70,7 @@ import {LoadingComponent} from '../../../shared/loading/loading.component'; import {translate, TranslocoDirective} from "@jsverse/transloco"; import {shareReplay} from "rxjs/operators"; import {DblClickDirective} from "../../../_directives/dbl-click.directive"; +import {ConfirmService} from "../../../shared/confirm.service"; const PREFETCH_PAGES = 10; @@ -150,9 +151,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { private readonly modalService = inject(NgbModal); private readonly cdRef = inject(ChangeDetectorRef); private readonly toastr = inject(ToastrService); - public readonly readerService = inject(ReaderService); - public readonly utilityService = inject(UtilityService); - public readonly mangaReaderService = inject(MangaReaderService); + private readonly confirmService = inject(ConfirmService); + protected readonly readerService = inject(ReaderService); + protected readonly utilityService = inject(UtilityService); + protected readonly mangaReaderService = inject(MangaReaderService); + protected readonly KeyDirection = KeyDirection; protected readonly ReaderMode = ReaderMode; @@ -647,7 +650,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } @HostListener('window:keyup', ['$event']) - handleKeyPress(event: KeyboardEvent) { + async handleKeyPress(event: KeyboardEvent) { switch (this.readerMode) { case ReaderMode.LeftRight: if (event.key === KEY_CODES.RIGHT_ARROW) { @@ -682,7 +685,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } else if (event.key === KEY_CODES.SPACE) { this.toggleMenu(); } else if (event.key === KEY_CODES.G) { - const goToPageNum = this.promptForPage(); + const goToPageNum = await this.promptForPage(); if (goToPageNum === null) { return; } this.goToPage(parseInt(goToPageNum.trim(), 10)); } else if (event.key === KEY_CODES.B) { @@ -1593,9 +1596,16 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } // This is menu only code - promptForPage() { - const question = translate('book-reader.go-to-page-prompt', {totalPages: this.maxPages}); - const goToPageNum = window.prompt(question, ''); + async promptForPage() { + // const question = translate('book-reader.go-to-page-prompt', {totalPages: this.maxPages}); + // const goToPageNum = window.prompt(question, ''); + + const promptConfig = {...this.confirmService.defaultPrompt}; + promptConfig.header = translate('book-reader.go-to-page'); + promptConfig.content = translate('book-reader.go-to-page-prompt', {totalPages: this.maxPages}); + + const goToPageNum = await this.confirmService.prompt(undefined, promptConfig); + if (goToPageNum === null || goToPageNum.trim().length === 0) { return null; } return goToPageNum; } diff --git a/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.html b/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.html index 6f36e9b5a..4a51435fc 100644 --- a/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.html +++ b/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.html @@ -16,7 +16,7 @@ } } @else { -
    +
    Ctrl+K
    } 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 9f45cd55a..1d1ce4c7e 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 @@ -83,7 +83,7 @@ } -
    +
    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 511811fe8..6e8e3b22a 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 @@ -58,6 +58,7 @@ 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"; +import {User} from "../../../_models/user"; enum TabID { Storyline = 'storyline-tab', @@ -251,7 +252,8 @@ export class ReadingListDetailComponent implements OnInit { if (user) { this.isAdmin = this.accountService.hasAdminRole(user); - this.actions = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this)) + this.actions = this.actionFactoryService + .getReadingListActions(this.handleReadingListActionCallback.bind(this), this.shouldRenderReadingListAction.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(); @@ -307,6 +309,17 @@ export class ReadingListDetailComponent implements OnInit { } } + shouldRenderReadingListAction(action: ActionItem, entity: ReadingList, user: User) { + switch (action.action) { + case Action.Promote: + return !entity.promoted; + case Action.UnPromote: + return entity.promoted; + default: + return true; + } + } + editReadingList(readingList: ReadingList) { this.actionService.editReadingList(readingList, (readingList: ReadingList) => { // Reload information around list diff --git a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html index dd7dcab9a..a66ec008f 100644 --- a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html @@ -21,7 +21,7 @@ [trackByIdentity]="trackByIdentity" > - this.readingListService.actionListFilter(action, readingList, this.isAdmin || this.hasPromote)); - - return this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this)) + return this.actionFactoryService + .getReadingListActions(this.handleReadingListActionCallback.bind(this), this.shouldRenderReadingListAction.bind(this)) .filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin || this.hasPromote)); } @@ -172,4 +171,15 @@ export class ReadingListsComponent implements OnInit { break; } } + + shouldRenderReadingListAction(action: ActionItem, entity: ReadingList, user: User) { + switch (action.action) { + case Action.Promote: + return !entity.promoted; + case Action.UnPromote: + return entity.promoted; + default: + return true; + } + } } 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 05580bed0..6353664f3 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 @@ -551,7 +551,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.location.replaceState(newUrl) } - handleSeriesActionCallback(action: ActionItem, series: Series) { + async handleSeriesActionCallback(action: ActionItem, series: Series) { this.cdRef.markForCheck(); switch(action.action) { case(Action.MarkAsRead): @@ -565,16 +565,16 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { }); break; case(Action.Scan): - this.actionService.scanSeries(series); + await this.actionService.scanSeries(series); break; case(Action.RefreshMetadata): - this.actionService.refreshSeriesMetadata(series, undefined, true, false); + await this.actionService.refreshSeriesMetadata(series, undefined, true, false); break; case(Action.GenerateColorScape): - this.actionService.refreshSeriesMetadata(series, undefined, false, true); + await this.actionService.refreshSeriesMetadata(series, undefined, false, true); break; case(Action.Delete): - this.deleteSeries(series); + await this.deleteSeries(series); break; case(Action.AddToReadingList): this.actionService.addSeriesToReadingList(series); @@ -645,6 +645,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.actionService.sendToDevice(volume.chapters.map(c => c.id), device); break; } + case (Action.Download): + this.downloadService.download('volume', volume); + break; default: break; } @@ -679,6 +682,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.cdRef.markForCheck(); }); break; + case (Action.Download): + this.downloadService.download('chapter', chapter); + break; default: break; } diff --git a/UI/Web/src/app/shared/confirm-dialog/_models/confirm-config.ts b/UI/Web/src/app/shared/confirm-dialog/_models/confirm-config.ts index 481c9b48c..7cfd257e2 100644 --- a/UI/Web/src/app/shared/confirm-dialog/_models/confirm-config.ts +++ b/UI/Web/src/app/shared/confirm-dialog/_models/confirm-config.ts @@ -1,7 +1,7 @@ -import { ConfirmButton } from './confirm-button'; +import {ConfirmButton} from './confirm-button'; export class ConfirmConfig { - _type: 'confirm' | 'alert' | 'info' = 'confirm'; + _type: 'confirm' | 'alert' | 'info' | 'prompt' = 'confirm'; header: string = 'Confirm'; content: string = ''; buttons: Array = []; diff --git a/UI/Web/src/app/shared/confirm-dialog/confirm-dialog.component.html b/UI/Web/src/app/shared/confirm-dialog/confirm-dialog.component.html index 21b741cd3..213c80ceb 100644 --- a/UI/Web/src/app/shared/confirm-dialog/confirm-dialog.component.html +++ b/UI/Web/src/app/shared/confirm-dialog/confirm-dialog.component.html @@ -5,8 +5,18 @@ }
    - + + @if (config._type === 'prompt') { + + } @else { + + } +
    diff --git a/UI/Web/src/app/typeahead/_models/selection-model.ts b/UI/Web/src/app/typeahead/_models/selection-model.ts index c4b2ab18a..8493a4eed 100644 --- a/UI/Web/src/app/typeahead/_models/selection-model.ts +++ b/UI/Web/src/app/typeahead/_models/selection-model.ts @@ -70,6 +70,28 @@ export class SelectionModel { return (selectedCount !== this._data.length && selectedCount !== 0) } + /** + * @return If at least one item is selected + */ + hasAnySelected(): boolean { + for (const d of this._data) { + if (d.selected) { + return true; + } + } + return false; + } + + /** + * Marks every data entry has not selected + */ + clearSelected() { + this._data = this._data.map(d => { + d.selected = false; + return d; + }); + } + /** * * @returns All Selected items diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 2a2d40c4f..91a3dac9e 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -42,6 +42,8 @@ "series-header": "Series", "data-header": "Data", "is-processed-header": "Is Processed", + "select-all-label": "Select all", + "delete-selected-label": "Delete selected", "no-data": "{{common.no-data}}", "volume-and-chapter-num": "Volume {{v}} Chapter {{n}}", "volume-num": "Volume {{num}}", From 225572732f44aadbe05b65d14be0c4e6b2cc88a1 Mon Sep 17 00:00:00 2001 From: majora2007 Date: Fri, 20 Jun 2025 19:10:12 +0000 Subject: [PATCH 37/57] Bump versions by dotnet-bump-version. --- Kavita.Common/Kavita.Common.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 9b590f97c..081ab80ca 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -3,7 +3,7 @@ net9.0 kavitareader.com Kavita - 0.8.6.16 + 0.8.6.17 en true From fa8d778c8da2e9bad77336c5ac4c03eae43a3575 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 20 Jun 2025 19:11:24 +0000 Subject: [PATCH 38/57] Update OpenAPI documentation --- openapi.json | 55 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/openapi.json b/openapi.json index 209dfe2ef..5f50b88f7 100644 --- a/openapi.json +++ b/openapi.json @@ -2,12 +2,12 @@ "openapi": "3.0.4", "info": { "title": "Kavita", - "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.6.15", + "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.6.16", "license": { "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.8.6.15" + "version": "0.8.6.16" }, "servers": [ { @@ -10522,7 +10522,7 @@ "tags": [ "Scrobbling" ], - "summary": "Adds a hold against the Series for user's scrobbling", + "summary": "Remove a hold against the Series for user's scrobbling", "parameters": [ { "name": "seriesId", @@ -10571,6 +10571,51 @@ } } }, + "/api/Scrobbling/bulk-remove-events": { + "post": { + "tags": [ + "Scrobbling" + ], + "summary": "Delete the given scrobble events if they belong to that user", + "requestBody": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + }, + "application/*+json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/Search/series-for-mangafile": { "get": { "tags": [ @@ -23505,6 +23550,10 @@ "ScrobbleEventDto": { "type": "object", "properties": { + "id": { + "type": "integer", + "format": "int64" + }, "seriesName": { "type": "string", "nullable": true From 36aa5f5c85cd49b13200c15f933f52548082a6e7 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Mon, 23 Jun 2025 18:57:14 -0500 Subject: [PATCH 39/57] Ability to turn off Metadata Parsing (#3872) --- API.Benchmark/API.Benchmark.csproj | 4 +- API.Tests/API.Tests.csproj | 6 +- API.Tests/Parsers/ComicVineParserTests.cs | 8 +- API.Tests/Parsers/DefaultParserTests.cs | 20 +- API.Tests/Parsers/ImageParserTests.cs | 6 +- API.Tests/Parsers/PdfParserTests.cs | 2 +- API.Tests/Parsing/ImageParsingTests.cs | 6 +- API.Tests/Parsing/MangaParsingTests.cs | 2 - API.Tests/Services/BookServiceTests.cs | 2 +- API.Tests/Services/CacheServiceTests.cs | 4 +- .../Services/ExternalMetadataServiceTests.cs | 2 +- API.Tests/Services/ParseScannedFilesTests.cs | 16 +- API.Tests/Services/ScannerServiceTests.cs | 38 +- ...es with Localized No Metadata - Manga.json | 5 + API/API.csproj | 38 +- API/Controllers/LibraryController.cs | 1 + .../ExternalMetadataIdsDto.cs | 2 +- .../ExternalMetadata/MatchSeriesRequestDto.cs | 2 +- .../SeriesDetailPlusApiDto.cs | 2 +- .../KavitaPlus/Metadata/ExternalChapterDto.cs | 1 + API/DTOs/LibraryDto.cs | 4 + API/DTOs/UpdateLibraryDto.cs | 2 + API/Data/DataContext.cs | 3 + ...20215058_EnableMetadataLibrary.Designer.cs | 3709 +++++++++++++++++ .../20250620215058_EnableMetadataLibrary.cs | 29 + .../Migrations/DataContextModelSnapshot.cs | 7 +- API/Entities/Library.cs | 4 + .../RestrictByLibraryExtensions.cs | 0 API/Helpers/Builders/LibraryBuilder.cs | 6 + API/Services/Plus/ExternalMetadataService.cs | 47 +- API/Services/Plus/KavitaPlusApiService.cs | 53 +- API/Services/ReadingItemService.cs | 20 +- .../Tasks/Scanner/ParseScannedFiles.cs | 4 +- .../Tasks/Scanner/Parser/BasicParser.cs | 11 +- .../Tasks/Scanner/Parser/BookParser.cs | 4 +- .../Tasks/Scanner/Parser/ComicVineParser.cs | 7 +- .../Tasks/Scanner/Parser/DefaultParser.cs | 5 +- .../Tasks/Scanner/Parser/ImageParser.cs | 2 +- API/Services/Tasks/Scanner/Parser/Parser.cs | 4 +- .../Tasks/Scanner/Parser/PdfParser.cs | 16 +- API/Services/Tasks/ScannerService.cs | 5 + API/SignalR/MessageFactory.cs | 16 + Kavita.Common/Configuration.cs | 2 +- Kavita.Common/Kavita.Common.csproj | 8 +- UI/Web/src/_tag-card-common.scss | 5 + UI/Web/src/app/_helpers/form-debug.ts | 120 + .../external-match-rate-limit-error-event.ts | 4 + UI/Web/src/app/_models/library/library.ts | 1 + .../src/app/_services/message-hub.service.ts | 30 +- UI/Web/src/app/_services/reader.service.ts | 8 +- .../manage-matched-metadata.component.ts | 22 +- .../browse-genres.component.html | 2 +- .../browse-genres/browse-genres.component.ts | 10 +- .../browse-tags/browse-tags.component.html | 2 +- .../browse-tags/browse-tags.component.ts | 10 +- .../reading-list-detail.component.html | 9 +- .../reading-list-detail.component.ts | 10 +- .../reading-list-item.component.html | 8 +- .../reading-list-item.component.ts | 10 +- .../external-rating.component.ts | 5 +- .../library-settings-modal.component.html | 10 + .../library-settings-modal.component.ts | 37 +- UI/Web/src/assets/langs/en.json | 5 +- 63 files changed, 4257 insertions(+), 186 deletions(-) create mode 100644 API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized No Metadata - Manga.json create mode 100644 API/Data/Migrations/20250620215058_EnableMetadataLibrary.Designer.cs create mode 100644 API/Data/Migrations/20250620215058_EnableMetadataLibrary.cs create mode 100644 API/Extensions/QueryExtensions/RestrictByLibraryExtensions.cs create mode 100644 UI/Web/src/app/_helpers/form-debug.ts create mode 100644 UI/Web/src/app/_models/events/external-match-rate-limit-error-event.ts diff --git a/API.Benchmark/API.Benchmark.csproj b/API.Benchmark/API.Benchmark.csproj index d6fd4eb9f..ec9c1884f 100644 --- a/API.Benchmark/API.Benchmark.csproj +++ b/API.Benchmark/API.Benchmark.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index 73b886e13..a571a6e72 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -6,13 +6,13 @@ - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/API.Tests/Parsers/ComicVineParserTests.cs b/API.Tests/Parsers/ComicVineParserTests.cs index f01e98afd..2f4fd568e 100644 --- a/API.Tests/Parsers/ComicVineParserTests.cs +++ b/API.Tests/Parsers/ComicVineParserTests.cs @@ -36,7 +36,7 @@ public class ComicVineParserTests public void Parse_SeriesWithComicInfo() { var actual = _parser.Parse("C:/Comics/Birds of Prey (2002)/Birds of Prey 001 (2002).cbz", "C:/Comics/Birds of Prey (2002)/", - RootDirectory, LibraryType.ComicVine, new ComicInfo() + RootDirectory, LibraryType.ComicVine, true, new ComicInfo() { Series = "Birds of Prey", Volume = "2002" @@ -54,7 +54,7 @@ public class ComicVineParserTests public void Parse_SeriesWithDirectoryNameAsSeriesYear() { var actual = _parser.Parse("C:/Comics/Birds of Prey (2002)/Birds of Prey 001 (2002).cbz", "C:/Comics/Birds of Prey (2002)/", - RootDirectory, LibraryType.ComicVine, null); + RootDirectory, LibraryType.ComicVine, true, null); Assert.NotNull(actual); Assert.Equal("Birds of Prey (2002)", actual.Series); @@ -69,7 +69,7 @@ public class ComicVineParserTests public void Parse_SeriesWithADirectoryNameAsSeriesYear() { var actual = _parser.Parse("C:/Comics/DC Comics/Birds of Prey (1999)/Birds of Prey 001 (1999).cbz", "C:/Comics/DC Comics/", - RootDirectory, LibraryType.ComicVine, null); + RootDirectory, LibraryType.ComicVine, true, null); Assert.NotNull(actual); Assert.Equal("Birds of Prey (1999)", actual.Series); @@ -84,7 +84,7 @@ public class ComicVineParserTests public void Parse_FallbackToDirectoryNameOnly() { var actual = _parser.Parse("C:/Comics/DC Comics/Blood Syndicate/Blood Syndicate 001 (1999).cbz", "C:/Comics/DC Comics/", - RootDirectory, LibraryType.ComicVine, null); + RootDirectory, LibraryType.ComicVine, true, null); Assert.NotNull(actual); Assert.Equal("Blood Syndicate", actual.Series); diff --git a/API.Tests/Parsers/DefaultParserTests.cs b/API.Tests/Parsers/DefaultParserTests.cs index 733b55d62..244c08b97 100644 --- a/API.Tests/Parsers/DefaultParserTests.cs +++ b/API.Tests/Parsers/DefaultParserTests.cs @@ -33,7 +33,7 @@ public class DefaultParserTests [InlineData("C:/", "C:/Something Random/Mujaki no Rakuen SP01.cbz", "Something Random")] public void ParseFromFallbackFolders_FallbackShouldParseSeries(string rootDir, string inputPath, string expectedSeries) { - var actual = _defaultParser.Parse(inputPath, rootDir, rootDir, LibraryType.Manga, null); + var actual = _defaultParser.Parse(inputPath, rootDir, rootDir, LibraryType.Manga, true, null); if (actual == null) { Assert.NotNull(actual); @@ -74,7 +74,7 @@ public class DefaultParserTests fs.AddFile(inputFile, new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), fs); var parser = new BasicParser(ds, new ImageParser(ds)); - var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, null); + var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, true, null); _defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual); Assert.Equal(expectedParseInfo, actual.Series); } @@ -90,7 +90,7 @@ public class DefaultParserTests fs.AddFile(inputFile, new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), fs); var parser = new BasicParser(ds, new ImageParser(ds)); - var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, null); + var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, true, null); _defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual); Assert.Equal(expectedParseInfo, actual.Series); } @@ -251,7 +251,7 @@ public class DefaultParserTests foreach (var file in expected.Keys) { var expectedInfo = expected[file]; - var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Manga, null); + var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Manga, true, null); if (expectedInfo == null) { Assert.Null(actual); @@ -289,7 +289,7 @@ public class DefaultParserTests Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image, FullFilePath = filepath, IsSpecial = false }; - var actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Monster #8", "E:/Manga", LibraryType.Manga, null); + var actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Monster #8", "E:/Manga", LibraryType.Manga, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -315,7 +315,7 @@ public class DefaultParserTests FullFilePath = filepath, IsSpecial = false }; - actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga",LibraryType.Manga, null); + actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga",LibraryType.Manga, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -341,7 +341,7 @@ public class DefaultParserTests FullFilePath = filepath, IsSpecial = false }; - actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga", LibraryType.Manga, null); + actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga", LibraryType.Manga, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -383,7 +383,7 @@ public class DefaultParserTests FullFilePath = filepath }; - var actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, null); + var actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, true, null); Assert.NotNull(actual); _testOutputHelper.WriteLine($"Validating {filepath}"); @@ -412,7 +412,7 @@ public class DefaultParserTests FullFilePath = filepath }; - actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, null); + actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, true, null); Assert.NotNull(actual); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expected.Format, actual.Format); @@ -475,7 +475,7 @@ public class DefaultParserTests foreach (var file in expected.Keys) { var expectedInfo = expected[file]; - var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Comic, null); + var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Comic, true, null); if (expectedInfo == null) { Assert.Null(actual); diff --git a/API.Tests/Parsers/ImageParserTests.cs b/API.Tests/Parsers/ImageParserTests.cs index f95c98ddf..63df1926e 100644 --- a/API.Tests/Parsers/ImageParserTests.cs +++ b/API.Tests/Parsers/ImageParserTests.cs @@ -34,7 +34,7 @@ public class ImageParserTests public void Parse_SeriesWithDirectoryName() { var actual = _parser.Parse("C:/Comics/Birds of Prey/Chapter 01/01.jpg", "C:/Comics/Birds of Prey/", - RootDirectory, LibraryType.Image, null); + RootDirectory, LibraryType.Image, true, null); Assert.NotNull(actual); Assert.Equal("Birds of Prey", actual.Series); @@ -48,7 +48,7 @@ public class ImageParserTests public void Parse_SeriesWithNoNestedChapter() { var actual = _parser.Parse("C:/Comics/Birds of Prey/Chapter 01 page 01.jpg", "C:/Comics/", - RootDirectory, LibraryType.Image, null); + RootDirectory, LibraryType.Image, true, null); Assert.NotNull(actual); Assert.Equal("Birds of Prey", actual.Series); @@ -62,7 +62,7 @@ public class ImageParserTests public void Parse_SeriesWithLooseImages() { var actual = _parser.Parse("C:/Comics/Birds of Prey/page 01.jpg", "C:/Comics/", - RootDirectory, LibraryType.Image, null); + RootDirectory, LibraryType.Image, true, null); Assert.NotNull(actual); Assert.Equal("Birds of Prey", actual.Series); diff --git a/API.Tests/Parsers/PdfParserTests.cs b/API.Tests/Parsers/PdfParserTests.cs index 72088526d..08bf9f25d 100644 --- a/API.Tests/Parsers/PdfParserTests.cs +++ b/API.Tests/Parsers/PdfParserTests.cs @@ -35,7 +35,7 @@ public class PdfParserTests { var actual = _parser.Parse("C:/Books/A Dictionary of Japanese Food - Ingredients and Culture/A Dictionary of Japanese Food - Ingredients and Culture.pdf", "C:/Books/A Dictionary of Japanese Food - Ingredients and Culture/", - RootDirectory, LibraryType.Book, null); + RootDirectory, LibraryType.Book, true, null); Assert.NotNull(actual); Assert.Equal("A Dictionary of Japanese Food - Ingredients and Culture", actual.Series); diff --git a/API.Tests/Parsing/ImageParsingTests.cs b/API.Tests/Parsing/ImageParsingTests.cs index 3d78d9372..362b4b08c 100644 --- a/API.Tests/Parsing/ImageParsingTests.cs +++ b/API.Tests/Parsing/ImageParsingTests.cs @@ -34,7 +34,7 @@ public class ImageParsingTests Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image, FullFilePath = filepath, IsSpecial = false }; - var actual2 = _parser.Parse(filepath, @"E:\Manga\Monster #8", "E:/Manga", LibraryType.Image, null); + var actual2 = _parser.Parse(filepath, @"E:\Manga\Monster #8", "E:/Manga", LibraryType.Image, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -60,7 +60,7 @@ public class ImageParsingTests FullFilePath = filepath, IsSpecial = false }; - actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, null); + actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -86,7 +86,7 @@ public class ImageParsingTests FullFilePath = filepath, IsSpecial = false }; - actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, null); + actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); diff --git a/API.Tests/Parsing/MangaParsingTests.cs b/API.Tests/Parsing/MangaParsingTests.cs index 8b93c5f90..53f2bc4c9 100644 --- a/API.Tests/Parsing/MangaParsingTests.cs +++ b/API.Tests/Parsing/MangaParsingTests.cs @@ -68,10 +68,8 @@ public class MangaParsingTests [InlineData("Манга Тома 1-4", "1-4")] [InlineData("Манга Том 1-4", "1-4")] [InlineData("조선왕조실톡 106화", "106")] - [InlineData("죽음 13회", "13")] [InlineData("동의보감 13장", "13")] [InlineData("몰?루 아카이브 7.5권", "7.5")] - [InlineData("주술회전 1.5권", "1.5")] [InlineData("63권#200", "63")] [InlineData("시즌34삽화2", "34")] [InlineData("Accel World Chapter 001 Volume 002", "2")] diff --git a/API.Tests/Services/BookServiceTests.cs b/API.Tests/Services/BookServiceTests.cs index a80c1ca01..5848c74ba 100644 --- a/API.Tests/Services/BookServiceTests.cs +++ b/API.Tests/Services/BookServiceTests.cs @@ -137,7 +137,7 @@ public class BookServiceTests var comicInfo = _bookService.GetComicInfo(filePath); Assert.NotNull(comicInfo); - var parserInfo = pdfParser.Parse(filePath, testDirectory, ds.GetParentDirectoryName(testDirectory), LibraryType.Book, comicInfo); + var parserInfo = pdfParser.Parse(filePath, testDirectory, ds.GetParentDirectoryName(testDirectory), LibraryType.Book, true, comicInfo); Assert.NotNull(parserInfo); Assert.Equal(parserInfo.Title, comicInfo.Title); Assert.Equal(parserInfo.Series, comicInfo.Title); diff --git a/API.Tests/Services/CacheServiceTests.cs b/API.Tests/Services/CacheServiceTests.cs index 5c1752cd8..caf1ae393 100644 --- a/API.Tests/Services/CacheServiceTests.cs +++ b/API.Tests/Services/CacheServiceTests.cs @@ -50,12 +50,12 @@ internal class MockReadingItemServiceForCacheService : IReadingItemService throw new System.NotImplementedException(); } - public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type) + public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true) { throw new System.NotImplementedException(); } - public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type) + public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true) { throw new System.NotImplementedException(); } diff --git a/API.Tests/Services/ExternalMetadataServiceTests.cs b/API.Tests/Services/ExternalMetadataServiceTests.cs index 833e8fe5f..8278f3b1a 100644 --- a/API.Tests/Services/ExternalMetadataServiceTests.cs +++ b/API.Tests/Services/ExternalMetadataServiceTests.cs @@ -42,7 +42,7 @@ public class ExternalMetadataServiceTests : AbstractDbTest _externalMetadataService = new ExternalMetadataService(UnitOfWork, Substitute.For>(), Mapper, Substitute.For(), Substitute.For(), Substitute.For(), - Substitute.For()); + Substitute.For(), Substitute.For()); } #region Gloabl diff --git a/API.Tests/Services/ParseScannedFilesTests.cs b/API.Tests/Services/ParseScannedFilesTests.cs index f8714f69a..a732b2526 100644 --- a/API.Tests/Services/ParseScannedFilesTests.cs +++ b/API.Tests/Services/ParseScannedFilesTests.cs @@ -58,35 +58,35 @@ public class MockReadingItemService : IReadingItemService throw new NotImplementedException(); } - public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type) + public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata) { if (_comicVineParser.IsApplicable(path, type)) { - return _comicVineParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _comicVineParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_imageParser.IsApplicable(path, type)) { - return _imageParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _imageParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_bookParser.IsApplicable(path, type)) { - return _bookParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _bookParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_pdfParser.IsApplicable(path, type)) { - return _pdfParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _pdfParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_basicParser.IsApplicable(path, type)) { - return _basicParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _basicParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } return null; } - public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type) + public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata) { - return Parse(path, rootPath, libraryRoot, type); + return Parse(path, rootPath, libraryRoot, type, enableMetadata); } } diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 2e812647b..acc0345b1 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -483,7 +483,7 @@ public class ScannerServiceTests : AbstractDbTest var infos = new Dictionary(); var library = await _scannerHelper.GenerateScannerData(testcase, infos); - library.LibraryExcludePatterns = [new LibraryExcludePattern() {Pattern = "**/Extra/*"}]; + library.LibraryExcludePatterns = [new LibraryExcludePattern() { Pattern = "**/Extra/*" }]; UnitOfWork.LibraryRepository.Update(library); await UnitOfWork.CommitAsync(); @@ -507,7 +507,7 @@ public class ScannerServiceTests : AbstractDbTest var infos = new Dictionary(); var library = await _scannerHelper.GenerateScannerData(testcase, infos); - library.LibraryExcludePatterns = [new LibraryExcludePattern() {Pattern = "**\\Extra\\*"}]; + library.LibraryExcludePatterns = [new LibraryExcludePattern() { Pattern = "**\\Extra\\*" }]; UnitOfWork.LibraryRepository.Update(library); await UnitOfWork.CommitAsync(); @@ -938,4 +938,38 @@ public class ScannerServiceTests : AbstractDbTest Assert.True(sortedChapters[1].SortOrder.Is(4f)); Assert.True(sortedChapters[2].SortOrder.Is(5f)); } + + + [Fact] + public async Task ScanLibrary_MetadataDisabled_NoOverrides() + { + const string testcase = "Series with Localized No Metadata - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("Immoral Guild v01.cbz", new ComicInfo() + { + Series = "Immoral Guild", + LocalizedSeries = "Futoku no Guild" // Filename has a capital N and localizedSeries has lowercase + }); + + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + // Disable metadata + library.EnableMetadata = false; + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + // Validate that there are 2 series + Assert.NotNull(postLib); + Assert.Equal(2, postLib.Series.Count); + + Assert.Contains(postLib.Series, x => x.Name == "Immoral Guild"); + Assert.Contains(postLib.Series, x => x.Name == "Futoku No Guild"); + } } diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized No Metadata - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized No Metadata - Manga.json new file mode 100644 index 000000000..d6e91183b --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized No Metadata - Manga.json @@ -0,0 +1,5 @@ +[ + "Immoral Guild/Immoral Guild v01.cbz", + "Immoral Guild/Immoral Guild v02.cbz", + "Immoral Guild/Futoku No Guild - Vol. 12 Ch. 67 - Take Responsibility.cbz" +] diff --git a/API/API.csproj b/API/API.csproj index 4eed66f22..a7d1177dc 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -50,9 +50,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -62,25 +62,25 @@ - + - + - - - - - + + + + + - - - + + + @@ -89,16 +89,16 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - + + diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 2f12aa1fe..c09011b77 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -623,6 +623,7 @@ public class LibraryController : BaseApiController library.ManageReadingLists = dto.ManageReadingLists; library.AllowScrobbling = dto.AllowScrobbling; library.AllowMetadataMatching = dto.AllowMetadataMatching; + library.EnableMetadata = dto.EnableMetadata; library.LibraryFileTypes = dto.FileGroupTypes .Select(t => new LibraryFileTypeGroup() {FileTypeGroup = t, LibraryId = library.Id}) .Distinct() diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs index 2b7dea8e6..c05ff0567 100644 --- a/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs +++ b/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs @@ -6,7 +6,7 @@ namespace API.DTOs.KavitaPlus.ExternalMetadata; /// /// Used for matching and fetching metadata on a series /// -internal sealed record ExternalMetadataIdsDto +public sealed record ExternalMetadataIdsDto { public long? MalId { get; set; } public int? AniListId { get; set; } diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs index fae674ded..a7359d69b 100644 --- a/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs +++ b/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs @@ -7,7 +7,7 @@ namespace API.DTOs.KavitaPlus.ExternalMetadata; /// /// Represents a request to match some series from Kavita to an external id which K+ uses. /// -internal sealed record MatchSeriesRequestDto +public sealed record MatchSeriesRequestDto { public required string SeriesName { get; set; } public ICollection AlternativeNames { get; set; } = []; diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs index d0cbb7bd3..84e9bbf3e 100644 --- a/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs +++ b/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs @@ -6,7 +6,7 @@ using API.DTOs.SeriesDetail; namespace API.DTOs.KavitaPlus.ExternalMetadata; -internal sealed record SeriesDetailPlusApiDto +public sealed record SeriesDetailPlusApiDto { public IEnumerable Recommendations { get; set; } public IEnumerable Reviews { get; set; } diff --git a/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs b/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs index 1dcd8494c..add9ca723 100644 --- a/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs +++ b/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using API.DTOs.SeriesDetail; namespace API.DTOs.KavitaPlus.Metadata; +#nullable enable /// /// Information about an individual issue/chapter/book from Kavita+ diff --git a/API/DTOs/LibraryDto.cs b/API/DTOs/LibraryDto.cs index 8ba687346..7b38379c9 100644 --- a/API/DTOs/LibraryDto.cs +++ b/API/DTOs/LibraryDto.cs @@ -66,4 +66,8 @@ public sealed record LibraryDto /// This does not exclude the library from being linked to wrt Series Relationships /// Requires a valid LicenseKey public bool AllowMetadataMatching { get; set; } = true; + /// + /// Allow Kavita to read metadata (ComicInfo.xml, Epub, PDF) + /// + public bool EnableMetadata { get; set; } = true; } diff --git a/API/DTOs/UpdateLibraryDto.cs b/API/DTOs/UpdateLibraryDto.cs index 9bd47fd39..68d2417ec 100644 --- a/API/DTOs/UpdateLibraryDto.cs +++ b/API/DTOs/UpdateLibraryDto.cs @@ -28,6 +28,8 @@ public sealed record UpdateLibraryDto public bool AllowScrobbling { get; init; } [Required] public bool AllowMetadataMatching { get; init; } + [Required] + public bool EnableMetadata { get; init; } /// /// What types of files to allow the scanner to pickup /// diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 3bbf45e23..aa8b67283 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -147,6 +147,9 @@ public sealed class DataContext : IdentityDbContext() .Property(b => b.AllowMetadataMatching) .HasDefaultValue(true); + builder.Entity() + .Property(b => b.EnableMetadata) + .HasDefaultValue(true); builder.Entity() .Property(b => b.WebLinks) diff --git a/API/Data/Migrations/20250620215058_EnableMetadataLibrary.Designer.cs b/API/Data/Migrations/20250620215058_EnableMetadataLibrary.Designer.cs new file mode 100644 index 000000000..c15f9f77b --- /dev/null +++ b/API/Data/Migrations/20250620215058_EnableMetadataLibrary.Designer.cs @@ -0,0 +1,3709 @@ +// +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("20250620215058_EnableMetadataLibrary")] + partial class EnableMetadataLibrary + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + 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("HasRunScrobbleEventGeneration") + .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("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.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.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.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.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("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("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("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("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("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("KoreaderHash") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.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("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.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.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.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.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.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.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.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("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + 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("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.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/20250620215058_EnableMetadataLibrary.cs b/API/Data/Migrations/20250620215058_EnableMetadataLibrary.cs new file mode 100644 index 000000000..f9e38c01d --- /dev/null +++ b/API/Data/Migrations/20250620215058_EnableMetadataLibrary.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class EnableMetadataLibrary : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EnableMetadata", + table: "Library", + type: "INTEGER", + nullable: false, + defaultValue: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EnableMetadata", + table: "Library"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index c9fb953df..eb786865b 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.4"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -1296,6 +1296,11 @@ namespace API.Data.Migrations b.Property("CreatedUtc") .HasColumnType("TEXT"); + b.Property("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.Property("FolderWatching") .HasColumnType("INTEGER"); diff --git a/API/Entities/Library.cs b/API/Entities/Library.cs index abab81378..8dc386298 100644 --- a/API/Entities/Library.cs +++ b/API/Entities/Library.cs @@ -48,6 +48,10 @@ public class Library : IEntityDate, IHasCoverImage /// This does not exclude the library from being linked to wrt Series Relationships /// Requires a valid LicenseKey public bool AllowMetadataMatching { get; set; } = true; + /// + /// Should Kavita read metadata files from the library + /// + public bool EnableMetadata { get; set; } = true; public DateTime Created { get; set; } diff --git a/API/Extensions/QueryExtensions/RestrictByLibraryExtensions.cs b/API/Extensions/QueryExtensions/RestrictByLibraryExtensions.cs new file mode 100644 index 000000000..e69de29bb diff --git a/API/Helpers/Builders/LibraryBuilder.cs b/API/Helpers/Builders/LibraryBuilder.cs index 30e6136a5..950c5d3d2 100644 --- a/API/Helpers/Builders/LibraryBuilder.cs +++ b/API/Helpers/Builders/LibraryBuilder.cs @@ -110,6 +110,12 @@ public class LibraryBuilder : IEntityBuilder return this; } + public LibraryBuilder WithEnableMetadata(bool enable) + { + _library.EnableMetadata = enable; + return this; + } + public LibraryBuilder WithAllowScrobbling(bool allowScrobbling) { _library.AllowScrobbling = allowScrobbling; diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index 435727bda..1db334b91 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -67,6 +67,7 @@ public class ExternalMetadataService : IExternalMetadataService private readonly IScrobblingService _scrobblingService; private readonly IEventHub _eventHub; private readonly ICoverDbService _coverDbService; + private readonly IKavitaPlusApiService _kavitaPlusApiService; private readonly TimeSpan _externalSeriesMetadataCache = TimeSpan.FromDays(30); public static readonly HashSet NonEligibleLibraryTypes = [LibraryType.Comic, LibraryType.Book, LibraryType.Image]; @@ -82,7 +83,8 @@ public class ExternalMetadataService : IExternalMetadataService 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) + ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService, + IKavitaPlusApiService kavitaPlusApiService) { _unitOfWork = unitOfWork; _logger = logger; @@ -91,6 +93,7 @@ public class ExternalMetadataService : IExternalMetadataService _scrobblingService = scrobblingService; _eventHub = eventHub; _coverDbService = coverDbService; + _kavitaPlusApiService = kavitaPlusApiService; FlurlConfiguration.ConfigureClientForUrl(Configuration.KavitaPlusApiUrl); } @@ -179,9 +182,7 @@ public class ExternalMetadataService : IExternalMetadataService _logger.LogDebug("Fetching Kavita+ for MAL Stacks for user {UserName}", user.MalUserName); var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; - var result = await ($"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/stacks?username={user.MalUserName}") - .WithKavitaPlusHeaders(license) - .GetJsonAsync>(); + var result = await _kavitaPlusApiService.GetMalStacks(user.MalUserName, license); if (result == null) { @@ -207,7 +208,7 @@ public class ExternalMetadataService : IExternalMetadataService /// public async Task> MatchSeries(MatchSeriesDto dto) { - var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, SeriesIncludes.Metadata | SeriesIncludes.ExternalMetadata | SeriesIncludes.Library); if (series == null) return []; @@ -239,14 +240,9 @@ public class ExternalMetadataService : IExternalMetadataService MalId = potentialMalId ?? ScrobblingService.GetMalId(series) }; - var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; - try { - var results = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/match-series") - .WithKavitaPlusHeaders(license, token) - .PostJsonAsync(matchRequest) - .ReceiveJson>(); + var results = await _kavitaPlusApiService.MatchSeries(matchRequest); // Some summaries can contain multiple
    s, we need to ensure it's only 1 foreach (var result in results) @@ -287,9 +283,7 @@ public class ExternalMetadataService : IExternalMetadataService } // This is for the Series drawer. We can get this extra information during the initial SeriesDetail call so it's all coming from the DB - - var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; - var details = await GetSeriesDetail(license, aniListId, malId, seriesId); + var details = await GetSeriesDetail(aniListId, malId, seriesId); return details; @@ -392,6 +386,9 @@ public class ExternalMetadataService : IExternalMetadataService { // We can't rethrow because Fix match is done in a background thread and Hangfire will requeue multiple times _logger.LogInformation(ex, "Rate limit hit for matching {SeriesName} with Kavita+", series.Name); + // Fire SignalR event about this + await _eventHub.SendMessageAsync(MessageFactory.ExternalMatchRateLimitError, + MessageFactory.ExternalMatchRateLimitErrorEvent(series.Id, series.Name)); } } @@ -442,16 +439,12 @@ public class ExternalMetadataService : IExternalMetadataService try { _logger.LogDebug("Fetching Kavita+ Series Detail data for {SeriesName}", string.IsNullOrEmpty(data.SeriesName) ? data.AniListId : data.SeriesName); - var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; - var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; SeriesDetailPlusApiDto? result = null; try { - result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail") - .WithKavitaPlusHeaders(license, token) - .PostJsonAsync(data) - .ReceiveJson(); // This returns an AniListSeries and Match returns ExternalSeriesDto + // This returns an AniListSeries and Match returns ExternalSeriesDto + result = await _kavitaPlusApiService.GetSeriesDetail(data); } catch (FlurlHttpException ex) { @@ -466,11 +459,7 @@ public class ExternalMetadataService : IExternalMetadataService _logger.LogDebug("Hit rate limit, will retry in 3 seconds"); await Task.Delay(3000); - result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail") - .WithKavitaPlusHeaders(license, token) - .PostJsonAsync(data) - .ReceiveJson< - SeriesDetailPlusApiDto>(); + result = await _kavitaPlusApiService.GetSeriesDetail(data); } else if (errorMessage.Contains("Unknown Series")) { @@ -1777,7 +1766,7 @@ public class ExternalMetadataService : IExternalMetadataService /// /// /// - private async Task GetSeriesDetail(string license, int? aniListId, long? malId, int? seriesId) + private async Task GetSeriesDetail(int? aniListId, long? malId, int? seriesId) { var payload = new ExternalMetadataIdsDto() { @@ -1809,11 +1798,7 @@ public class ExternalMetadataService : IExternalMetadataService } try { - var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; - var ret = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids") - .WithKavitaPlusHeaders(license, token) - .PostJsonAsync(payload) - .ReceiveJson(); + var ret = await _kavitaPlusApiService.GetSeriesDetailById(payload); ret.Summary = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(ret.Summary)); diff --git a/API/Services/Plus/KavitaPlusApiService.cs b/API/Services/Plus/KavitaPlusApiService.cs index cdf9471f8..ec4f414c3 100644 --- a/API/Services/Plus/KavitaPlusApiService.cs +++ b/API/Services/Plus/KavitaPlusApiService.cs @@ -1,6 +1,13 @@ #nullable enable +using System.Collections.Generic; using System.Threading.Tasks; +using API.Data; +using API.DTOs.Collection; +using API.DTOs.KavitaPlus.ExternalMetadata; +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Metadata.Matching; using API.DTOs.Scrobbling; +using API.Entities.Enums; using API.Extensions; using Flurl.Http; using Kavita.Common; @@ -17,9 +24,13 @@ public interface IKavitaPlusApiService Task HasTokenExpired(string license, string token, ScrobbleProvider provider); Task GetRateLimit(string license, string token); Task PostScrobbleUpdate(ScrobbleDto data, string license); + Task> GetMalStacks(string malUsername, string license); + Task> MatchSeries(MatchSeriesRequestDto request); + Task GetSeriesDetail(PlusSeriesRequestDto request); + Task GetSeriesDetailById(ExternalMetadataIdsDto request); } -public class KavitaPlusApiService(ILogger logger): IKavitaPlusApiService +public class KavitaPlusApiService(ILogger logger, IUnitOfWork unitOfWork): IKavitaPlusApiService { private const string ScrobblingPath = "/api/scrobbling/"; @@ -42,6 +53,46 @@ public class KavitaPlusApiService(ILogger logger): IKavita return await PostAndReceive(ScrobblingPath + "update", data, license); } + public async Task> GetMalStacks(string malUsername, string license) + { + return await $"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/stacks?username={malUsername}" + .WithKavitaPlusHeaders(license) + .GetJsonAsync>(); + } + + public async Task> MatchSeries(MatchSeriesRequestDto request) + { + var license = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + var token = (await unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; + + return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/match-series") + .WithKavitaPlusHeaders(license, token) + .PostJsonAsync(request) + .ReceiveJson>(); + } + + public async Task GetSeriesDetail(PlusSeriesRequestDto request) + { + var license = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + var token = (await unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; + + return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail") + .WithKavitaPlusHeaders(license, token) + .PostJsonAsync(request) + .ReceiveJson(); + } + + public async Task GetSeriesDetailById(ExternalMetadataIdsDto request) + { + var license = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + var token = (await unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; + + return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids") + .WithKavitaPlusHeaders(license, token) + .PostJsonAsync(request) + .ReceiveJson(); + } + /// /// Send a GET request to K+ /// diff --git a/API/Services/ReadingItemService.cs b/API/Services/ReadingItemService.cs index efdaec8ff..6ff8d19de 100644 --- a/API/Services/ReadingItemService.cs +++ b/API/Services/ReadingItemService.cs @@ -12,7 +12,7 @@ public interface IReadingItemService int GetNumberOfPages(string filePath, MangaFormat format); string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default); void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1); - ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type); + ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata); } public class ReadingItemService : IReadingItemService @@ -71,11 +71,12 @@ public class ReadingItemService : IReadingItemService /// Path of a file /// /// Library type to determine parsing to perform - public ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type) + /// Enable Metadata parsing overriding filename parsing + public ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata) { try { - var info = Parse(path, rootPath, libraryRoot, type); + var info = Parse(path, rootPath, libraryRoot, type, enableMetadata); if (info == null) { _logger.LogError("Unable to parse any meaningful information out of file {FilePath}", path); @@ -174,28 +175,29 @@ public class ReadingItemService : IReadingItemService /// /// /// + /// /// - private ParserInfo? Parse(string path, string rootPath, string libraryRoot, LibraryType type) + private ParserInfo? Parse(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata) { if (_comicVineParser.IsApplicable(path, type)) { - return _comicVineParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _comicVineParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_imageParser.IsApplicable(path, type)) { - return _imageParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _imageParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_bookParser.IsApplicable(path, type)) { - return _bookParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _bookParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_pdfParser.IsApplicable(path, type)) { - return _pdfParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _pdfParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_basicParser.IsApplicable(path, type)) { - return _basicParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _basicParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } return null; diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index c3f36ef2e..83558eaa0 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -804,7 +804,7 @@ public class ParseScannedFiles { // Process files sequentially result.ParserInfos = files - .Select(file => _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type)) + .Select(file => _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type, library.EnableMetadata)) .Where(info => info != null) .ToList()!; } @@ -812,7 +812,7 @@ public class ParseScannedFiles { // Process files in parallel var tasks = files.Select(file => Task.Run(() => - _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type))); + _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type, library.EnableMetadata))); var infos = await Task.WhenAll(tasks); result.ParserInfos = infos.Where(info => info != null).ToList()!; diff --git a/API/Services/Tasks/Scanner/Parser/BasicParser.cs b/API/Services/Tasks/Scanner/Parser/BasicParser.cs index 1462ab3d3..168ca7f01 100644 --- a/API/Services/Tasks/Scanner/Parser/BasicParser.cs +++ b/API/Services/Tasks/Scanner/Parser/BasicParser.cs @@ -12,7 +12,7 @@ namespace API.Services.Tasks.Scanner.Parser; ///
    public class BasicParser(IDirectoryService directoryService, IDefaultParser imageParser) : DefaultParser(directoryService) { - public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null) + public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null) { var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); // TODO: Potential Bug: This will return null, but on Image libraries, if all images, we would want to include this. @@ -20,7 +20,7 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag if (Parser.IsImage(filePath)) { - return imageParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Image, comicInfo); + return imageParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Image, enableMetadata, comicInfo); } var ret = new ParserInfo() @@ -101,7 +101,12 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag } // Patch in other information from ComicInfo - UpdateFromComicInfo(ret); + if (enableMetadata) + { + UpdateFromComicInfo(ret); + } + + if (ret.Volumes == Parser.LooseLeafVolume && ret.Chapters == Parser.DefaultChapter) { diff --git a/API/Services/Tasks/Scanner/Parser/BookParser.cs b/API/Services/Tasks/Scanner/Parser/BookParser.cs index 499e554ef..14f42c989 100644 --- a/API/Services/Tasks/Scanner/Parser/BookParser.cs +++ b/API/Services/Tasks/Scanner/Parser/BookParser.cs @@ -5,7 +5,7 @@ namespace API.Services.Tasks.Scanner.Parser; public class BookParser(IDirectoryService directoryService, IBookService bookService, BasicParser basicParser) : DefaultParser(directoryService) { - public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo comicInfo = null) + public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo comicInfo = null) { var info = bookService.ParseInfo(filePath); if (info == null) return null; @@ -35,7 +35,7 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer } else { - var info2 = basicParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Book, comicInfo); + var info2 = basicParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Book, enableMetadata, comicInfo); info.Merge(info2); if (hasVolumeInSeries && info2 != null && Parser.ParseVolume(info2.Series, type) .Equals(Parser.LooseLeafVolume)) diff --git a/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs b/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs index b68596245..b60f28aee 100644 --- a/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs +++ b/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs @@ -19,7 +19,7 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser /// /// /// - public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null) + public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null) { if (type != LibraryType.ComicVine) return null; @@ -81,7 +81,10 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser info.IsSpecial = Parser.IsSpecial(info.Filename, type) || Parser.IsSpecial(info.ComicInfo?.Format, type); // Patch in other information from ComicInfo - UpdateFromComicInfo(info); + if (enableMetadata) + { + UpdateFromComicInfo(info); + } if (string.IsNullOrEmpty(info.Series)) { diff --git a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs index 679d6a031..687617fd7 100644 --- a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs +++ b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs @@ -8,7 +8,7 @@ namespace API.Services.Tasks.Scanner.Parser; public interface IDefaultParser { - ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null); + ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null); void ParseFromFallbackFolders(string filePath, string rootPath, LibraryType type, ref ParserInfo ret); bool IsApplicable(string filePath, LibraryType type); } @@ -26,8 +26,9 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau /// /// Root folder /// Allows different Regex to be used for parsing. + /// Allows overriding data from metadata (ComicInfo/pdf/epub) /// or null if Series was empty - public abstract ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null); + public abstract ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null); /// /// Fills out by trying to parse volume, chapters, and series from folders diff --git a/API/Services/Tasks/Scanner/Parser/ImageParser.cs b/API/Services/Tasks/Scanner/Parser/ImageParser.cs index 415533631..12f9f4d50 100644 --- a/API/Services/Tasks/Scanner/Parser/ImageParser.cs +++ b/API/Services/Tasks/Scanner/Parser/ImageParser.cs @@ -7,7 +7,7 @@ namespace API.Services.Tasks.Scanner.Parser; public class ImageParser(IDirectoryService directoryService) : DefaultParser(directoryService) { - public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null) + public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null) { if (!IsApplicable(filePath, type)) return null; diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index c8eb010b3..c0b130f91 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -165,9 +165,9 @@ public static partial class Parser new Regex( @"(卷|册)(?\d+)", MatchOptions, RegexTimeout), - // Korean Volume: 제n화|권|회|장 -> Volume n, n화|권|회|장 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside) + // Korean Volume: 제n화|회|장 -> Volume n, n화|권|장 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside) new Regex( - @"제?(?\d+(\.\d+)?)(권|회|화|장)", + @"제?(?\d+(\.\d+)?)(권|화|장)", MatchOptions, RegexTimeout), // Korean Season: 시즌n -> Season n, new Regex( diff --git a/API/Services/Tasks/Scanner/Parser/PdfParser.cs b/API/Services/Tasks/Scanner/Parser/PdfParser.cs index bc12e2c77..80bfa9a48 100644 --- a/API/Services/Tasks/Scanner/Parser/PdfParser.cs +++ b/API/Services/Tasks/Scanner/Parser/PdfParser.cs @@ -6,7 +6,7 @@ namespace API.Services.Tasks.Scanner.Parser; public class PdfParser(IDirectoryService directoryService) : DefaultParser(directoryService) { - public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo comicInfo = null) + public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo comicInfo = null) { var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); var ret = new ParserInfo @@ -68,14 +68,18 @@ public class PdfParser(IDirectoryService directoryService) : DefaultParser(direc ParseFromFallbackFolders(filePath, tempRootPath, type, ref ret); } - // Patch in other information from ComicInfo - UpdateFromComicInfo(ret); - - if (comicInfo != null && !string.IsNullOrEmpty(comicInfo.Title)) + if (enableMetadata) { - ret.Title = comicInfo.Title.Trim(); + // Patch in other information from ComicInfo + UpdateFromComicInfo(ret); + + if (comicInfo != null && !string.IsNullOrEmpty(comicInfo.Title)) + { + ret.Title = comicInfo.Title.Trim(); + } } + if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.LooseLeafVolume && type == LibraryType.Book) { ret.IsSpecial = true; diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index e22ee4bb6..cb5f4302f 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -521,6 +521,11 @@ public class ScannerService : IScannerService // Validations are done, now we can start actual scan _logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name); + if (!library.EnableMetadata) + { + _logger.LogInformation("[ScannerService] Warning! {LibraryName} has metadata turned off", library.Name); + } + // This doesn't work for something like M:/Manga/ and a series has library folder as root var shouldUseLibraryScan = !(await _unitOfWork.LibraryRepository.DoAnySeriesFoldersMatch(libraryFolderPaths)); if (!shouldUseLibraryScan) diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index ba967d8a6..87a464e6a 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -152,6 +152,10 @@ public static class MessageFactory /// A Person merged has been merged into another /// public const string PersonMerged = "PersonMerged"; + /// + /// A Rate limit error was hit when matching a series with Kavita+ + /// + public const string ExternalMatchRateLimitError = "ExternalMatchRateLimitError"; public static SignalRMessage DashboardUpdateEvent(int userId) { @@ -679,4 +683,16 @@ public static class MessageFactory }, }; } + public static SignalRMessage ExternalMatchRateLimitErrorEvent(int seriesId, string seriesName) + { + return new SignalRMessage() + { + Name = ExternalMatchRateLimitError, + Body = new + { + seriesId = seriesId, + seriesName = seriesName, + }, + }; + } } diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index f2d64cde6..ba4fd09b7 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -17,7 +17,7 @@ public static class Configuration private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename()); public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development - ? "http://localhost:5020" : "https://plus.kavitareader.com"; + ? "https://plus.kavitareader.com" : "https://plus.kavitareader.com"; // http://localhost:5020 public static readonly string StatsApiUrl = "https://stats.kavitareader.com"; public static int Port diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 081ab80ca..b920416bb 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -9,12 +9,12 @@ - + - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/UI/Web/src/_tag-card-common.scss b/UI/Web/src/_tag-card-common.scss index 07f37c2a0..39a1e87fd 100644 --- a/UI/Web/src/_tag-card-common.scss +++ b/UI/Web/src/_tag-card-common.scss @@ -5,6 +5,11 @@ box-shadow: 0 2px 5px rgba(0,0,0,0.2); transition: transform 0.2s ease, background 0.3s ease; cursor: pointer; + + &.not-selectable:hover { + cursor: not-allowed; + background-color: var(--bs-card-color, #2c2c2c) !important; + } } .tag-card:hover { diff --git a/UI/Web/src/app/_helpers/form-debug.ts b/UI/Web/src/app/_helpers/form-debug.ts new file mode 100644 index 000000000..4ad70ac87 --- /dev/null +++ b/UI/Web/src/app/_helpers/form-debug.ts @@ -0,0 +1,120 @@ +import {AbstractControl, FormArray, FormControl, FormGroup} from '@angular/forms'; + +interface ValidationIssue { + path: string; + controlType: string; + value: any; + errors: { [key: string]: any } | null; + status: string; + disabled: boolean; +} + +export function analyzeFormGroupValidation(formGroup: FormGroup, basePath: string = ''): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + function analyzeControl(control: AbstractControl, path: string): void { + // Determine control type for better debugging + let controlType = 'AbstractControl'; + if (control instanceof FormGroup) { + controlType = 'FormGroup'; + } else if (control instanceof FormArray) { + controlType = 'FormArray'; + } else if (control instanceof FormControl) { + controlType = 'FormControl'; + } + + // Add issue if control has validation errors or is invalid + if (control.invalid || control.errors || control.disabled) { + issues.push({ + path: path || 'root', + controlType, + value: control.value, + errors: control.errors, + status: control.status, + disabled: control.disabled + }); + } + + // Recursively check nested controls + if (control instanceof FormGroup) { + Object.keys(control.controls).forEach(key => { + const childPath = path ? `${path}.${key}` : key; + analyzeControl(control.controls[key], childPath); + }); + } else if (control instanceof FormArray) { + control.controls.forEach((childControl, index) => { + const childPath = path ? `${path}[${index}]` : `[${index}]`; + analyzeControl(childControl, childPath); + }); + } + } + + analyzeControl(formGroup, basePath); + return issues; +} + +export function printFormGroupValidation(formGroup: FormGroup, basePath: string = ''): void { + const issues = analyzeFormGroupValidation(formGroup, basePath); + + console.group(`🔍 FormGroup Validation Analysis (${basePath || 'root'})`); + console.log(`Overall Status: ${formGroup.status}`); + console.log(`Overall Valid: ${formGroup.valid}`); + console.log(`Total Issues Found: ${issues.length}`); + + if (issues.length === 0) { + console.log('✅ No validation issues found!'); + } else { + console.log('\n📋 Detailed Issues:'); + issues.forEach((issue, index) => { + console.group(`${index + 1}. ${issue.path} (${issue.controlType})`); + console.log(`Status: ${issue.status}`); + console.log(`Value:`, issue.value); + console.log(`Disabled: ${issue.disabled}`); + + if (issue.errors) { + console.log('Validation Errors:'); + Object.entries(issue.errors).forEach(([errorKey, errorValue]) => { + console.log(` • ${errorKey}:`, errorValue); + }); + } else { + console.log('No specific validation errors (but control is invalid)'); + } + console.groupEnd(); + }); + } + + console.groupEnd(); +} + +// Alternative function that returns a formatted string instead of console logging +export function getFormGroupValidationReport(formGroup: FormGroup, basePath: string = ''): string { + const issues = analyzeFormGroupValidation(formGroup, basePath); + + let report = `FormGroup Validation Report (${basePath || 'root'})\n`; + report += `Overall Status: ${formGroup.status}\n`; + report += `Overall Valid: ${formGroup.valid}\n`; + report += `Total Issues Found: ${issues.length}\n\n`; + + if (issues.length === 0) { + report += '✅ No validation issues found!'; + } else { + report += 'Detailed Issues:\n'; + issues.forEach((issue, index) => { + report += `\n${index + 1}. ${issue.path} (${issue.controlType})\n`; + report += ` Status: ${issue.status}\n`; + report += ` Value: ${JSON.stringify(issue.value)}\n`; + report += ` Disabled: ${issue.disabled}\n`; + + if (issue.errors) { + report += ' Validation Errors:\n'; + Object.entries(issue.errors).forEach(([errorKey, errorValue]) => { + report += ` • ${errorKey}: ${JSON.stringify(errorValue)}\n`; + }); + } else { + report += ' No specific validation errors (but control is invalid)\n'; + } + }); + } + + return report; +} diff --git a/UI/Web/src/app/_models/events/external-match-rate-limit-error-event.ts b/UI/Web/src/app/_models/events/external-match-rate-limit-error-event.ts new file mode 100644 index 000000000..3695651d6 --- /dev/null +++ b/UI/Web/src/app/_models/events/external-match-rate-limit-error-event.ts @@ -0,0 +1,4 @@ +export interface ExternalMatchRateLimitErrorEvent { + seriesId: number; + seriesName: string; +} diff --git a/UI/Web/src/app/_models/library/library.ts b/UI/Web/src/app/_models/library/library.ts index bad83f54b..0e7d90ee2 100644 --- a/UI/Web/src/app/_models/library/library.ts +++ b/UI/Web/src/app/_models/library/library.ts @@ -31,6 +31,7 @@ export interface Library { manageReadingLists: boolean; allowScrobbling: boolean; allowMetadataMatching: boolean; + enableMetadata: boolean; collapseSeriesRelationships: boolean; libraryFileTypes: Array; excludePatterns: Array; diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index 67f07f32e..f870d1449 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -1,15 +1,16 @@ -import { Injectable } from '@angular/core'; -import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr'; -import { BehaviorSubject, ReplaySubject } from 'rxjs'; -import { environment } from 'src/environments/environment'; -import { LibraryModifiedEvent } from '../_models/events/library-modified-event'; -import { NotificationProgressEvent } from '../_models/events/notification-progress-event'; -import { ThemeProgressEvent } from '../_models/events/theme-progress-event'; -import { UserUpdateEvent } from '../_models/events/user-update-event'; -import { User } from '../_models/user'; +import {Injectable} from '@angular/core'; +import {HubConnection, HubConnectionBuilder} from '@microsoft/signalr'; +import {BehaviorSubject, ReplaySubject} from 'rxjs'; +import {environment} from 'src/environments/environment'; +import {LibraryModifiedEvent} from '../_models/events/library-modified-event'; +import {NotificationProgressEvent} from '../_models/events/notification-progress-event'; +import {ThemeProgressEvent} from '../_models/events/theme-progress-event'; +import {UserUpdateEvent} from '../_models/events/user-update-event'; +import {User} from '../_models/user'; import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event"; import {SideNavUpdateEvent} from "../_models/events/sidenav-update-event"; import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event"; +import {ExternalMatchRateLimitErrorEvent} from "../_models/events/external-match-rate-limit-error-event"; export enum EVENTS { UpdateAvailable = 'UpdateAvailable', @@ -114,6 +115,10 @@ export enum EVENTS { * A Person merged has been merged into another */ PersonMerged = 'PersonMerged', + /** + * A Rate limit error was hit when matching a series with Kavita+ + */ + ExternalMatchRateLimitError = 'ExternalMatchRateLimitError' } export interface Message { @@ -236,6 +241,13 @@ export class MessageHubService { }); }); + this.hubConnection.on(EVENTS.ExternalMatchRateLimitError, resp => { + this.messagesSource.next({ + event: EVENTS.ExternalMatchRateLimitError, + payload: resp.body as ExternalMatchRateLimitErrorEvent + }); + }); + this.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => { this.messagesSource.next({ event: EVENTS.NotificationProgress, diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 05958ee61..52aef2a4a 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -266,13 +266,13 @@ export class ReaderService { getQueryParamsObject(incognitoMode: boolean = false, readingListMode: boolean = false, readingListId: number = -1) { - let params: {[key: string]: any} = {}; - if (incognitoMode) { - params['incognitoMode'] = true; - } + const params: {[key: string]: any} = {}; + params['incognitoMode'] = incognitoMode; + if (readingListMode) { params['readingListId'] = readingListId; } + return params; } diff --git a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts index 223b309da..2a5582145 100644 --- a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts +++ b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts @@ -1,7 +1,7 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; import {LicenseService} from "../../_services/license.service"; import {Router} from "@angular/router"; -import {TranslocoDirective} from "@jsverse/transloco"; +import {translate, TranslocoDirective} from "@jsverse/transloco"; import {ImageComponent} from "../../shared/image/image.component"; import {ImageService} from "../../_services/image.service"; import {Series} from "../../_models/series"; @@ -23,6 +23,8 @@ import {EVENTS, MessageHubService} from "../../_services/message-hub.service"; import {ScanSeriesEvent} from "../../_models/events/scan-series-event"; import {LibraryTypePipe} from "../../_pipes/library-type.pipe"; import {allKavitaPlusMetadataApplicableTypes} from "../../_models/library/library"; +import {ExternalMatchRateLimitErrorEvent} from "../../_models/events/external-match-rate-limit-error-event"; +import {ToastrService} from "ngx-toastr"; @Component({ selector: 'app-manage-matched-metadata', @@ -55,6 +57,7 @@ export class ManageMatchedMetadataComponent implements OnInit { private readonly manageService = inject(ManageService); private readonly messageHub = inject(MessageHubService); private readonly cdRef = inject(ChangeDetectorRef); + private readonly toastr = inject(ToastrService); protected readonly imageService = inject(ImageService); @@ -74,12 +77,19 @@ export class ManageMatchedMetadataComponent implements OnInit { } this.messageHub.messages$.subscribe(message => { - if (message.event !== EVENTS.ScanSeries) return; - - const evt = message.payload as ScanSeriesEvent; - if (this.data.filter(d => d.series.id === evt.seriesId).length > 0) { - this.loadData(); + if (message.event == EVENTS.ScanSeries) { + const evt = message.payload as ScanSeriesEvent; + if (this.data.filter(d => d.series.id === evt.seriesId).length > 0) { + this.loadData(); + } } + + if (message.event == EVENTS.ExternalMatchRateLimitError) { + const evt = message.payload as ExternalMatchRateLimitErrorEvent; + this.toastr.error(translate('toasts.external-match-rate-error', {seriesName: evt.seriesName})) + } + + }); this.filterGroup.valueChanges.pipe( diff --git a/UI/Web/src/app/browse/browse-genres/browse-genres.component.html b/UI/Web/src/app/browse/browse-genres/browse-genres.component.html index 5eef2c91f..8166ef12c 100644 --- a/UI/Web/src/app/browse/browse-genres/browse-genres.component.html +++ b/UI/Web/src/app/browse/browse-genres/browse-genres.component.html @@ -19,7 +19,7 @@ > -
    +
    {{ item.title }}
    {{t('series-count', {num: item.seriesCount | compactNumber})}} diff --git a/UI/Web/src/app/browse/browse-genres/browse-genres.component.ts b/UI/Web/src/app/browse/browse-genres/browse-genres.component.ts index 02c2a8ead..c46795e25 100644 --- a/UI/Web/src/app/browse/browse-genres/browse-genres.component.ts +++ b/UI/Web/src/app/browse/browse-genres/browse-genres.component.ts @@ -1,6 +1,6 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, OnInit} from '@angular/core'; import {CardDetailLayoutComponent} from "../../cards/card-detail-layout/card-detail-layout.component"; -import {DecimalPipe} from "@angular/common"; +import {DecimalPipe, NgClass} from "@angular/common"; import { SideNavCompanionBarComponent } from "../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component"; @@ -24,7 +24,8 @@ import {Title} from "@angular/platform-browser"; DecimalPipe, SideNavCompanionBarComponent, TranslocoDirective, - CompactNumberPipe + CompactNumberPipe, + NgClass ], templateUrl: './browse-genres.component.html', styleUrl: './browse-genres.component.scss', @@ -62,7 +63,8 @@ export class BrowseGenresComponent implements OnInit { }); } - openFilter(field: FilterField, value: string | number) { - this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe(); + openFilter(field: FilterField, genre: BrowseGenre) { + if (genre.seriesCount === 0) return; // We don't yet have an issue page + this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${genre.id}`).subscribe(); } } diff --git a/UI/Web/src/app/browse/browse-tags/browse-tags.component.html b/UI/Web/src/app/browse/browse-tags/browse-tags.component.html index dcd59bb1f..627e05584 100644 --- a/UI/Web/src/app/browse/browse-tags/browse-tags.component.html +++ b/UI/Web/src/app/browse/browse-tags/browse-tags.component.html @@ -19,7 +19,7 @@ > -
    +
    {{ item.title }}
    {{t('series-count', {num: item.seriesCount | compactNumber})}} diff --git a/UI/Web/src/app/browse/browse-tags/browse-tags.component.ts b/UI/Web/src/app/browse/browse-tags/browse-tags.component.ts index 92910b0b9..05abb6300 100644 --- a/UI/Web/src/app/browse/browse-tags/browse-tags.component.ts +++ b/UI/Web/src/app/browse/browse-tags/browse-tags.component.ts @@ -1,6 +1,6 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, OnInit} from '@angular/core'; import {CardDetailLayoutComponent} from "../../cards/card-detail-layout/card-detail-layout.component"; -import {DecimalPipe} from "@angular/common"; +import {DecimalPipe, NgClass} from "@angular/common"; import { SideNavCompanionBarComponent } from "../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component"; @@ -25,7 +25,8 @@ import {Title} from "@angular/platform-browser"; DecimalPipe, SideNavCompanionBarComponent, TranslocoDirective, - CompactNumberPipe + CompactNumberPipe, + NgClass ], templateUrl: './browse-tags.component.html', styleUrl: './browse-tags.component.scss', @@ -61,7 +62,8 @@ export class BrowseTagsComponent implements OnInit { }); } - openFilter(field: FilterField, value: string | number) { - this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe(); + openFilter(field: FilterField, tag: BrowseTag) { + if (tag.seriesCount === 0) return; // We don't yet have an issue page + this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${tag.id}`).subscribe(); } } 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 1d1ce4c7e..7433c26c3 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 @@ -229,14 +229,17 @@
    } - + 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 6e8e3b22a..3056d7eb5 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 @@ -25,7 +25,8 @@ import {ImageService} from 'src/app/_services/image.service'; import {ReadingListService} from 'src/app/_services/reading-list.service'; import { DraggableOrderedListComponent, - IndexUpdateEvent + IndexUpdateEvent, + ItemRemoveEvent } from '../draggable-ordered-list/draggable-ordered-list.component'; import {forkJoin, startWith, tap} from 'rxjs'; import {ReaderService} from 'src/app/_services/reader.service'; @@ -321,6 +322,7 @@ export class ReadingListDetailComponent implements OnInit { } editReadingList(readingList: ReadingList) { + if (!readingList) return; this.actionService.editReadingList(readingList, (readingList: ReadingList) => { // Reload information around list this.readingListService.getReadingList(this.listId).subscribe(rl => { @@ -347,10 +349,10 @@ export class ReadingListDetailComponent implements OnInit { }); } - itemRemoved(item: ReadingListItem, position: number) { + removeItem(removeEvent: ItemRemoveEvent) { if (!this.readingList) return; - this.readingListService.deleteItem(this.readingList.id, item.id).subscribe(() => { - this.items.splice(position, 1); + this.readingListService.deleteItem(this.readingList.id, removeEvent.item.id).subscribe(() => { + this.items.splice(removeEvent.position, 1); this.items = [...this.items]; this.cdRef.markForCheck(); this.toastr.success(translate('toasts.item-removed')); 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 901ee270a..6421205ab 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 @@ -18,10 +18,10 @@ {{item.title}}
    @if (showRemove) { - } 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 acde50022..7ce6f6790 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 @@ -9,6 +9,7 @@ import {ImageComponent} from '../../../shared/image/image.component'; import {TranslocoDirective} from "@jsverse/transloco"; import {SeriesFormatComponent} from "../../../shared/series-format/series-format.component"; import {ReadMoreComponent} from "../../../shared/read-more/read-more.component"; +import {ItemRemoveEvent} from "../draggable-ordered-list/draggable-ordered-list.component"; @Component({ selector: 'app-reading-list-item', @@ -33,9 +34,16 @@ export class ReadingListItemComponent { @Input() promoted: boolean = false; @Output() read: EventEmitter = new EventEmitter(); - @Output() remove: EventEmitter = new EventEmitter(); + @Output() remove: EventEmitter = new EventEmitter(); readChapter(item: ReadingListItem) { this.read.emit(item); } + + removeItem(item: ReadingListItem) { + this.remove.emit({ + item: item, + position: item.order + }); + } } diff --git a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts index 8685adc48..d18939c4e 100644 --- a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts +++ b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts @@ -61,7 +61,8 @@ export class ExternalRatingComponent implements OnInit { ngOnInit() { this.reviewService.overallRating(this.seriesId, this.chapterId).subscribe(r => { this.overallRating = r.averageScore; - }); + this.cdRef.markForCheck(); + }); } updateRating(rating: number) { @@ -92,6 +93,4 @@ export class ExternalRatingComponent implements OnInit { return ''; } - - protected readonly RatingAuthority = RatingAuthority; } diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html index 8cbac271a..ff97fcbb0 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html @@ -127,6 +127,16 @@
    +
    + + +
    + +
    +
    +
    +
    +
    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 797124c4f..d0fed5c81 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 @@ -105,15 +105,16 @@ export class LibrarySettingsModalComponent implements OnInit { libraryForm: FormGroup = new FormGroup({ name: new FormControl('', { nonNullable: true, validators: [Validators.required] }), type: new FormControl(LibraryType.Manga, { nonNullable: true, validators: [Validators.required] }), - folderWatching: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), - includeInDashboard: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), - includeInRecommended: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), - includeInSearch: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), - manageCollections: new FormControl(false, { nonNullable: true, validators: [Validators.required] }), - manageReadingLists: new FormControl(false, { nonNullable: true, validators: [Validators.required] }), - allowScrobbling: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), - allowMetadataMatching: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), - collapseSeriesRelationships: new FormControl(false, { nonNullable: true, validators: [Validators.required] }), + folderWatching: new FormControl(true, { nonNullable: true, validators: [] }), + includeInDashboard: new FormControl(true, { nonNullable: true, validators: [] }), + includeInRecommended: new FormControl(true, { nonNullable: true, validators: [] }), + includeInSearch: new FormControl(true, { nonNullable: true, validators: [] }), + manageCollections: new FormControl(false, { nonNullable: true, validators: [] }), + manageReadingLists: new FormControl(false, { nonNullable: true, validators: [] }), + allowScrobbling: new FormControl(true, { nonNullable: true, validators: [] }), + allowMetadataMatching: new FormControl(true, { nonNullable: true, validators: [] }), + collapseSeriesRelationships: new FormControl(false, { nonNullable: true, validators: [] }), + enableMetadata: new FormControl(true, { nonNullable: true, validators: [] }), // required validator doesn't check value, just if true }); selectedFolders: string[] = []; @@ -155,7 +156,7 @@ export class LibrarySettingsModalComponent implements OnInit { this.libraryForm.get('allowScrobbling')?.disable(); if (this.IsMetadataDownloadEligible) { - this.libraryForm.get('allowMetadataMatching')?.setValue(this.library.allowMetadataMatching); + this.libraryForm.get('allowMetadataMatching')?.setValue(this.library.allowMetadataMatching ?? true); this.libraryForm.get('allowMetadataMatching')?.enable(); } else { this.libraryForm.get('allowMetadataMatching')?.setValue(false); @@ -184,6 +185,20 @@ export class LibrarySettingsModalComponent implements OnInit { this.setValues(); + // Turn on/off manage collections/rl + this.libraryForm.get('enableMetadata')?.valueChanges.pipe( + tap(enabled => { + const manageCollectionsFc = this.libraryForm.get('manageCollections'); + const manageReadingListsFc = this.libraryForm.get('manageReadingLists'); + + manageCollectionsFc?.setValue(enabled); + manageReadingListsFc?.setValue(enabled); + + this.cdRef.markForCheck(); + }), + takeUntilDestroyed(this.destroyRef) + ).subscribe(); + // This needs to only apply after first render this.libraryForm.get('type')?.valueChanges.pipe( tap((type: LibraryType) => { @@ -257,6 +272,8 @@ export class LibrarySettingsModalComponent implements OnInit { this.libraryForm.get('collapseSeriesRelationships')?.setValue(this.library.collapseSeriesRelationships); this.libraryForm.get('allowScrobbling')?.setValue(this.IsKavitaPlusEligible ? this.library.allowScrobbling : false); this.libraryForm.get('allowMetadataMatching')?.setValue(this.IsMetadataDownloadEligible ? this.library.allowMetadataMatching : false); + this.libraryForm.get('excludePatterns')?.setValue(this.excludePatterns ? this.library.excludePatterns : false); + this.libraryForm.get('enableMetadata')?.setValue(this.library.enableMetadata, true); this.selectedFolders = this.library.folders; this.madeChanges = false; diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 91a3dac9e..c6b8c823f 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -1129,6 +1129,8 @@ "include-in-dashboard-tooltip": "Should series from the library be included on the Dashboard. This affects all streams, like On Deck, Recently Updated, Recently Added, or any custom additions.", "include-in-search-label": "Include in Search", "include-in-search-tooltip": "Should series and any derived information (genres, people, files) from the library be included in search results.", + "enable-metadata-label": "Enable Metadata (ComicInfo/Epub/PDF)", + "enable-metadata-tooltip": "Allow Kavita to read metadata files which override filename parsing.", "force-scan": "Force Scan", "force-scan-tooltip": "This will force a scan on the library, treating like a fresh scan", "reset": "{{common.reset}}", @@ -2743,7 +2745,8 @@ "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.", "series-bound-to-reading-profile": "Series bound to Reading Profile {{name}}", - "library-bound-to-reading-profile": "Library bound to Reading Profile {{name}}" + "library-bound-to-reading-profile": "Library bound to Reading Profile {{name}}", + "external-match-rate-error": "Kavita ran out of rate looking up {{seriesName}}. Try again in 5 minutes." }, "read-time-pipe": { From d536cc7f6a3f6b9e7fb00bdc6d460c2bfad86104 Mon Sep 17 00:00:00 2001 From: majora2007 Date: Mon, 23 Jun 2025 23:57:53 +0000 Subject: [PATCH 40/57] Bump versions by dotnet-bump-version. --- Kavita.Common/Kavita.Common.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index b920416bb..fb13c5605 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -3,7 +3,7 @@ net9.0 kavitareader.com Kavita - 0.8.6.17 + 0.8.6.18 en true From 62231d3c4e0b8b3f33ef3f91c7912cd422512cf9 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 23 Jun 2025 23:58:56 +0000 Subject: [PATCH 41/57] Update OpenAPI documentation --- openapi.json | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/openapi.json b/openapi.json index 5f50b88f7..0ee2657d0 100644 --- a/openapi.json +++ b/openapi.json @@ -2,12 +2,12 @@ "openapi": "3.0.4", "info": { "title": "Kavita", - "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.6.16", + "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.6.17", "license": { "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.8.6.16" + "version": "0.8.6.17" }, "servers": [ { @@ -21341,6 +21341,10 @@ "type": "boolean", "description": "Allow any series within this Library to download metadata." }, + "enableMetadata": { + "type": "boolean", + "description": "Should Kavita read metadata files from the library" + }, "created": { "type": "string", "format": "date-time" @@ -21499,6 +21503,10 @@ "allowMetadataMatching": { "type": "boolean", "description": "Allow any series within this Library to download metadata." + }, + "enableMetadata": { + "type": "boolean", + "description": "Allow Kavita to read metadata (ComicInfo.xml, Epub, PDF)" } }, "additionalProperties": false @@ -26367,6 +26375,7 @@ "required": [ "allowMetadataMatching", "allowScrobbling", + "enableMetadata", "excludePatterns", "fileGroupTypes", "folders", @@ -26428,6 +26437,9 @@ "allowMetadataMatching": { "type": "boolean" }, + "enableMetadata": { + "type": "boolean" + }, "fileGroupTypes": { "type": "array", "items": { From 6fa1cf994efe23096f05289dcb7cad91e9430ef3 Mon Sep 17 00:00:00 2001 From: Fesaa <77553571+Fesaa@users.noreply.github.com> Date: Wed, 25 Jun 2025 17:04:26 +0200 Subject: [PATCH 42/57] A bunch of bug fixes and some enhancements (#3871) Co-authored-by: Joseph Milazzo --- API.Tests/Repository/GenreRepositoryTests.cs | 280 ++++++++++++++ API.Tests/Repository/PersonRepositoryTests.cs | 342 ++++++++++++++++++ API.Tests/Repository/TagRepositoryTests.cs | 278 ++++++++++++++ .../Services/ExternalMetadataServiceTests.cs | 212 +++++++++++ API/Controllers/PersonController.cs | 3 +- .../Metadata/ExternalSeriesDetailDto.cs | 2 + API/Data/Repositories/GenreRepository.cs | 16 +- API/Data/Repositories/PersonRepository.cs | 68 ++-- API/Data/Repositories/TagRepository.cs | 14 +- API/Extensions/EnumerableExtensions.cs | 13 + .../RestrictByAgeExtensions.cs | 26 ++ .../RestrictByLibraryExtensions.cs | 31 ++ API/Helpers/Builders/ChapterBuilder.cs | 20 + API/Helpers/Builders/SeriesMetadataBuilder.cs | 19 + API/Services/Plus/ExternalMetadataService.cs | 101 +++++- API/Services/TaskScheduler.cs | 4 +- UI/Web/src/app/_services/nav.service.ts | 52 +++ .../user-scrobble-history.component.html | 2 +- .../manage-metadata-settings.component.ts | 11 +- .../nav-header/nav-header.component.html | 15 +- .../nav-header/nav-header.component.ts | 8 - .../nav-link-modal.component.html | 25 +- .../nav-link-modal.component.ts | 8 +- .../person-detail/person-detail.component.ts | 5 +- 24 files changed, 1464 insertions(+), 91 deletions(-) create mode 100644 API.Tests/Repository/GenreRepositoryTests.cs create mode 100644 API.Tests/Repository/PersonRepositoryTests.cs create mode 100644 API.Tests/Repository/TagRepositoryTests.cs diff --git a/API.Tests/Repository/GenreRepositoryTests.cs b/API.Tests/Repository/GenreRepositoryTests.cs new file mode 100644 index 000000000..d197a91ba --- /dev/null +++ b/API.Tests/Repository/GenreRepositoryTests.cs @@ -0,0 +1,280 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Metadata.Browse; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Metadata; +using API.Helpers; +using API.Helpers.Builders; +using Xunit; + +namespace API.Tests.Repository; + +public class GenreRepositoryTests : AbstractDbTest +{ + private AppUser _fullAccess; + private AppUser _restrictedAccess; + private AppUser _restrictedAgeAccess; + + protected override async Task ResetDb() + { + Context.Genre.RemoveRange(Context.Genre); + Context.Library.RemoveRange(Context.Library); + await Context.SaveChangesAsync(); + } + + private TestGenreSet CreateTestGenres() + { + return new TestGenreSet + { + SharedSeriesChaptersGenre = new GenreBuilder("Shared Series Chapter Genre").Build(), + SharedSeriesGenre = new GenreBuilder("Shared Series Genre").Build(), + SharedChaptersGenre = new GenreBuilder("Shared Chapters Genre").Build(), + Lib0SeriesChaptersGenre = new GenreBuilder("Lib0 Series Chapter Genre").Build(), + Lib0SeriesGenre = new GenreBuilder("Lib0 Series Genre").Build(), + Lib0ChaptersGenre = new GenreBuilder("Lib0 Chapters Genre").Build(), + Lib1SeriesChaptersGenre = new GenreBuilder("Lib1 Series Chapter Genre").Build(), + Lib1SeriesGenre = new GenreBuilder("Lib1 Series Genre").Build(), + Lib1ChaptersGenre = new GenreBuilder("Lib1 Chapters Genre").Build(), + Lib1ChapterAgeGenre = new GenreBuilder("Lib1 Chapter Age Genre").Build() + }; + } + + private async Task SeedDbWithGenres(TestGenreSet genres) + { + await CreateTestUsers(); + await AddGenresToContext(genres); + await CreateLibrariesWithGenres(genres); + await AssignLibrariesToUsers(); + } + + private async Task CreateTestUsers() + { + _fullAccess = new AppUserBuilder("amelia", "amelia@example.com").Build(); + _restrictedAccess = new AppUserBuilder("mila", "mila@example.com").Build(); + _restrictedAgeAccess = new AppUserBuilder("eva", "eva@example.com").Build(); + _restrictedAgeAccess.AgeRestriction = AgeRating.Teen; + _restrictedAgeAccess.AgeRestrictionIncludeUnknowns = true; + + Context.Users.Add(_fullAccess); + Context.Users.Add(_restrictedAccess); + Context.Users.Add(_restrictedAgeAccess); + await Context.SaveChangesAsync(); + } + + private async Task AddGenresToContext(TestGenreSet genres) + { + var allGenres = genres.GetAllGenres(); + Context.Genre.AddRange(allGenres); + await Context.SaveChangesAsync(); + } + + private async Task CreateLibrariesWithGenres(TestGenreSet genres) + { + var lib0 = new LibraryBuilder("lib0") + .WithSeries(new SeriesBuilder("lib0-s0") + .WithMetadata(new SeriesMetadataBuilder() + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedSeriesGenre, genres.Lib0SeriesChaptersGenre, genres.Lib0SeriesGenre]) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib0SeriesChaptersGenre, genres.Lib0ChaptersGenre]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre]) + .Build()) + .Build()) + .Build()) + .Build(); + + var lib1 = new LibraryBuilder("lib1") + .WithSeries(new SeriesBuilder("lib1-s0") + .WithMetadata(new SeriesMetadataBuilder() + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedSeriesGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1SeriesGenre]) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre, genres.Lib1ChapterAgeGenre]) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("lib1-s1") + .WithMetadata(new SeriesMetadataBuilder() + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedSeriesGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1SeriesGenre]) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre]) + .Build()) + .Build()) + .Build()) + .Build(); + + Context.Library.Add(lib0); + Context.Library.Add(lib1); + await Context.SaveChangesAsync(); + } + + private async Task AssignLibrariesToUsers() + { + var lib0 = Context.Library.First(l => l.Name == "lib0"); + var lib1 = Context.Library.First(l => l.Name == "lib1"); + + _fullAccess.Libraries.Add(lib0); + _fullAccess.Libraries.Add(lib1); + _restrictedAccess.Libraries.Add(lib1); + _restrictedAgeAccess.Libraries.Add(lib1); + + await Context.SaveChangesAsync(); + } + + private static Predicate ContainsGenreCheck(Genre genre) + { + return g => g.Id == genre.Id; + } + + private static void AssertGenrePresent(IEnumerable genres, Genre expectedGenre) + { + Assert.Contains(genres, ContainsGenreCheck(expectedGenre)); + } + + private static void AssertGenreNotPresent(IEnumerable genres, Genre expectedGenre) + { + Assert.DoesNotContain(genres, ContainsGenreCheck(expectedGenre)); + } + + private static BrowseGenreDto GetGenreDto(IEnumerable genres, Genre genre) + { + return genres.First(dto => dto.Id == genre.Id); + } + + [Fact] + public async Task GetBrowseableGenre_FullAccess_ReturnsAllGenresWithCorrectCounts() + { + // Arrange + await ResetDb(); + var genres = CreateTestGenres(); + await SeedDbWithGenres(genres); + + // Act + var fullAccessGenres = await UnitOfWork.GenreRepository.GetBrowseableGenre(_fullAccess.Id, new UserParams()); + + // Assert + Assert.Equal(genres.GetAllGenres().Count, fullAccessGenres.TotalCount); + + foreach (var genre in genres.GetAllGenres()) + { + AssertGenrePresent(fullAccessGenres, genre); + } + + // Verify counts - 1 lib0 series, 2 lib1 series = 3 total series + Assert.Equal(3, GetGenreDto(fullAccessGenres, genres.SharedSeriesChaptersGenre).SeriesCount); + Assert.Equal(6, GetGenreDto(fullAccessGenres, genres.SharedSeriesChaptersGenre).ChapterCount); + Assert.Equal(1, GetGenreDto(fullAccessGenres, genres.Lib0SeriesGenre).SeriesCount); + } + + [Fact] + public async Task GetBrowseableGenre_RestrictedAccess_ReturnsOnlyAccessibleGenres() + { + // Arrange + await ResetDb(); + var genres = CreateTestGenres(); + await SeedDbWithGenres(genres); + + // Act + var restrictedAccessGenres = await UnitOfWork.GenreRepository.GetBrowseableGenre(_restrictedAccess.Id, new UserParams()); + + // Assert - Should see: 3 shared + 4 library 1 specific = 7 genres + Assert.Equal(7, restrictedAccessGenres.TotalCount); + + // Verify shared and Library 1 genres are present + AssertGenrePresent(restrictedAccessGenres, genres.SharedSeriesChaptersGenre); + AssertGenrePresent(restrictedAccessGenres, genres.SharedSeriesGenre); + AssertGenrePresent(restrictedAccessGenres, genres.SharedChaptersGenre); + AssertGenrePresent(restrictedAccessGenres, genres.Lib1SeriesChaptersGenre); + AssertGenrePresent(restrictedAccessGenres, genres.Lib1SeriesGenre); + AssertGenrePresent(restrictedAccessGenres, genres.Lib1ChaptersGenre); + AssertGenrePresent(restrictedAccessGenres, genres.Lib1ChapterAgeGenre); + + // Verify Library 0 specific genres are not present + AssertGenreNotPresent(restrictedAccessGenres, genres.Lib0SeriesChaptersGenre); + AssertGenreNotPresent(restrictedAccessGenres, genres.Lib0SeriesGenre); + AssertGenreNotPresent(restrictedAccessGenres, genres.Lib0ChaptersGenre); + + // Verify counts - 2 lib1 series + Assert.Equal(2, GetGenreDto(restrictedAccessGenres, genres.SharedSeriesChaptersGenre).SeriesCount); + Assert.Equal(4, GetGenreDto(restrictedAccessGenres, genres.SharedSeriesChaptersGenre).ChapterCount); + Assert.Equal(2, GetGenreDto(restrictedAccessGenres, genres.Lib1SeriesGenre).SeriesCount); + Assert.Equal(4, GetGenreDto(restrictedAccessGenres, genres.Lib1ChaptersGenre).ChapterCount); + Assert.Equal(1, GetGenreDto(restrictedAccessGenres, genres.Lib1ChapterAgeGenre).ChapterCount); + } + + [Fact] + public async Task GetBrowseableGenre_RestrictedAgeAccess_FiltersAgeRestrictedContent() + { + // Arrange + await ResetDb(); + var genres = CreateTestGenres(); + await SeedDbWithGenres(genres); + + // Act + var restrictedAgeAccessGenres = await UnitOfWork.GenreRepository.GetBrowseableGenre(_restrictedAgeAccess.Id, new UserParams()); + + // Assert - Should see: 3 shared + 3 lib1 specific = 6 genres (age-restricted genre filtered out) + Assert.Equal(6, restrictedAgeAccessGenres.TotalCount); + + // Verify accessible genres are present + AssertGenrePresent(restrictedAgeAccessGenres, genres.SharedSeriesChaptersGenre); + AssertGenrePresent(restrictedAgeAccessGenres, genres.SharedSeriesGenre); + AssertGenrePresent(restrictedAgeAccessGenres, genres.SharedChaptersGenre); + AssertGenrePresent(restrictedAgeAccessGenres, genres.Lib1SeriesChaptersGenre); + AssertGenrePresent(restrictedAgeAccessGenres, genres.Lib1SeriesGenre); + AssertGenrePresent(restrictedAgeAccessGenres, genres.Lib1ChaptersGenre); + + // Verify age-restricted genre is filtered out + AssertGenreNotPresent(restrictedAgeAccessGenres, genres.Lib1ChapterAgeGenre); + + // Verify counts - 1 series lib1 (age-restricted series filtered out) + Assert.Equal(1, GetGenreDto(restrictedAgeAccessGenres, genres.SharedSeriesChaptersGenre).SeriesCount); + Assert.Equal(1, GetGenreDto(restrictedAgeAccessGenres, genres.Lib1SeriesGenre).SeriesCount); + + // These values represent a bug - chapters are not properly filtered when their series is age-restricted + // Should be 2, but currently returns 3 due to the filtering issue + Assert.Equal(3, GetGenreDto(restrictedAgeAccessGenres, genres.SharedSeriesChaptersGenre).ChapterCount); + Assert.Equal(3, GetGenreDto(restrictedAgeAccessGenres, genres.Lib1ChaptersGenre).ChapterCount); + } + + private class TestGenreSet + { + public Genre SharedSeriesChaptersGenre { get; set; } + public Genre SharedSeriesGenre { get; set; } + public Genre SharedChaptersGenre { get; set; } + public Genre Lib0SeriesChaptersGenre { get; set; } + public Genre Lib0SeriesGenre { get; set; } + public Genre Lib0ChaptersGenre { get; set; } + public Genre Lib1SeriesChaptersGenre { get; set; } + public Genre Lib1SeriesGenre { get; set; } + public Genre Lib1ChaptersGenre { get; set; } + public Genre Lib1ChapterAgeGenre { get; set; } + + public List GetAllGenres() + { + return + [ + SharedSeriesChaptersGenre, SharedSeriesGenre, SharedChaptersGenre, + Lib0SeriesChaptersGenre, Lib0SeriesGenre, Lib0ChaptersGenre, + Lib1SeriesChaptersGenre, Lib1SeriesGenre, Lib1ChaptersGenre, Lib1ChapterAgeGenre + ]; + } + } +} diff --git a/API.Tests/Repository/PersonRepositoryTests.cs b/API.Tests/Repository/PersonRepositoryTests.cs new file mode 100644 index 000000000..a2b19cc0c --- /dev/null +++ b/API.Tests/Repository/PersonRepositoryTests.cs @@ -0,0 +1,342 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Metadata.Browse; +using API.DTOs.Metadata.Browse.Requests; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Person; +using API.Helpers; +using API.Helpers.Builders; +using Xunit; + +namespace API.Tests.Repository; + +public class PersonRepositoryTests : AbstractDbTest +{ + private AppUser _fullAccess; + private AppUser _restrictedAccess; + private AppUser _restrictedAgeAccess; + + protected override async Task ResetDb() + { + Context.Person.RemoveRange(Context.Person.ToList()); + Context.Library.RemoveRange(Context.Library.ToList()); + Context.AppUser.RemoveRange(Context.AppUser.ToList()); + await UnitOfWork.CommitAsync(); + } + + private async Task SeedDb() + { + _fullAccess = new AppUserBuilder("amelia", "amelia@example.com").Build(); + _restrictedAccess = new AppUserBuilder("mila", "mila@example.com").Build(); + _restrictedAgeAccess = new AppUserBuilder("eva", "eva@example.com").Build(); + _restrictedAgeAccess.AgeRestriction = AgeRating.Teen; + _restrictedAgeAccess.AgeRestrictionIncludeUnknowns = true; + + Context.AppUser.Add(_fullAccess); + Context.AppUser.Add(_restrictedAccess); + Context.AppUser.Add(_restrictedAgeAccess); + await Context.SaveChangesAsync(); + + var people = CreateTestPeople(); + Context.Person.AddRange(people); + await Context.SaveChangesAsync(); + + var libraries = CreateTestLibraries(people); + Context.Library.AddRange(libraries); + await Context.SaveChangesAsync(); + + _fullAccess.Libraries.Add(libraries[0]); // lib0 + _fullAccess.Libraries.Add(libraries[1]); // lib1 + _restrictedAccess.Libraries.Add(libraries[1]); // lib1 only + _restrictedAgeAccess.Libraries.Add(libraries[1]); // lib1 only + + await Context.SaveChangesAsync(); + } + + private static List CreateTestPeople() + { + return new List + { + new PersonBuilder("Shared Series Chapter Person").Build(), + new PersonBuilder("Shared Series Person").Build(), + new PersonBuilder("Shared Chapters Person").Build(), + new PersonBuilder("Lib0 Series Chapter Person").Build(), + new PersonBuilder("Lib0 Series Person").Build(), + new PersonBuilder("Lib0 Chapters Person").Build(), + new PersonBuilder("Lib1 Series Chapter Person").Build(), + new PersonBuilder("Lib1 Series Person").Build(), + new PersonBuilder("Lib1 Chapters Person").Build(), + new PersonBuilder("Lib1 Chapter Age Person").Build() + }; + } + + private static List CreateTestLibraries(List people) + { + var lib0 = new LibraryBuilder("lib0") + .WithSeries(new SeriesBuilder("lib0-s0") + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Writer) + .WithPerson(GetPersonByName(people, "Shared Series Person"), PersonRole.Writer) + .WithPerson(GetPersonByName(people, "Lib0 Series Chapter Person"), PersonRole.Writer) + .WithPerson(GetPersonByName(people, "Lib0 Series Person"), PersonRole.Writer) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Colorist) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Colorist) + .WithPerson(GetPersonByName(people, "Lib0 Series Chapter Person"), PersonRole.Colorist) + .WithPerson(GetPersonByName(people, "Lib0 Chapters Person"), PersonRole.Colorist) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Editor) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Editor) + .WithPerson(GetPersonByName(people, "Lib0 Series Chapter Person"), PersonRole.Editor) + .WithPerson(GetPersonByName(people, "Lib0 Chapters Person"), PersonRole.Editor) + .Build()) + .Build()) + .Build()) + .Build(); + + var lib1 = new LibraryBuilder("lib1") + .WithSeries(new SeriesBuilder("lib1-s0") + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Letterer) + .WithPerson(GetPersonByName(people, "Shared Series Person"), PersonRole.Letterer) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Letterer) + .WithPerson(GetPersonByName(people, "Lib1 Series Person"), PersonRole.Letterer) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Imprint) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Imprint) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Imprint) + .WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.Imprint) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.CoverArtist) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.CoverArtist) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.CoverArtist) + .WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.CoverArtist) + .WithPerson(GetPersonByName(people, "Lib1 Chapter Age Person"), PersonRole.CoverArtist) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("lib1-s1") + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Inker) + .WithPerson(GetPersonByName(people, "Shared Series Person"), PersonRole.Inker) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Inker) + .WithPerson(GetPersonByName(people, "Lib1 Series Person"), PersonRole.Inker) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Team) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Team) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Team) + .WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.Team) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Translator) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Translator) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Translator) + .WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.Translator) + .Build()) + .Build()) + .Build()) + .Build(); + + return new List { lib0, lib1 }; + } + + private static Person GetPersonByName(List people, string name) + { + return people.First(p => p.Name == name); + } + + private Person GetPersonByName(string name) + { + return Context.Person.First(p => p.Name == name); + } + + private static Predicate ContainsPersonCheck(Person person) + { + return p => p.Id == person.Id; + } + + [Fact] + public async Task GetBrowsePersonDtos() + { + await ResetDb(); + await SeedDb(); + + // Get people from database for assertions + var sharedSeriesChaptersPerson = GetPersonByName("Shared Series Chapter Person"); + var lib0SeriesPerson = GetPersonByName("Lib0 Series Person"); + var lib1SeriesPerson = GetPersonByName("Lib1 Series Person"); + var lib1ChapterAgePerson = GetPersonByName("Lib1 Chapter Age Person"); + var allPeople = Context.Person.ToList(); + + var fullAccessPeople = + await UnitOfWork.PersonRepository.GetBrowsePersonDtos(_fullAccess.Id, new BrowsePersonFilterDto(), + new UserParams()); + Assert.Equal(allPeople.Count, fullAccessPeople.TotalCount); + + foreach (var person in allPeople) + Assert.Contains(fullAccessPeople, ContainsPersonCheck(person)); + + // 1 series in lib0, 2 series in lib1 + Assert.Equal(3, fullAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).SeriesCount); + // 3 series with each 2 chapters + Assert.Equal(6, fullAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).ChapterCount); + // 1 series in lib0 + Assert.Equal(1, fullAccessPeople.First(dto => dto.Id == lib0SeriesPerson.Id).SeriesCount); + // 2 series in lib1 + Assert.Equal(2, fullAccessPeople.First(dto => dto.Id == lib1SeriesPerson.Id).SeriesCount); + + var restrictedAccessPeople = + await UnitOfWork.PersonRepository.GetBrowsePersonDtos(_restrictedAccess.Id, new BrowsePersonFilterDto(), + new UserParams()); + + Assert.Equal(7, restrictedAccessPeople.TotalCount); + + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Shared Series Chapter Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Shared Series Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Shared Chapters Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Series Chapter Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Series Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Chapters Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Chapter Age Person"))); + + // 2 series in lib1, no series in lib0 + Assert.Equal(2, restrictedAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).SeriesCount); + // 2 series with each 2 chapters + Assert.Equal(4, restrictedAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).ChapterCount); + // 2 series in lib1 + Assert.Equal(2, restrictedAccessPeople.First(dto => dto.Id == lib1SeriesPerson.Id).SeriesCount); + + var restrictedAgeAccessPeople = await UnitOfWork.PersonRepository.GetBrowsePersonDtos(_restrictedAgeAccess.Id, + new BrowsePersonFilterDto(), new UserParams()); + + // Note: There is a potential bug here where a person in a different chapter of an age restricted series will show up + Assert.Equal(6, restrictedAgeAccessPeople.TotalCount); + + // No access to the age restricted chapter + Assert.DoesNotContain(restrictedAgeAccessPeople, ContainsPersonCheck(lib1ChapterAgePerson)); + } + + [Fact] + public async Task GetRolesForPersonByName() + { + await ResetDb(); + await SeedDb(); + + var sharedSeriesPerson = GetPersonByName("Shared Series Person"); + var sharedChaptersPerson = GetPersonByName("Shared Chapters Person"); + var lib1ChapterAgePerson = GetPersonByName("Lib1 Chapter Age Person"); + + var sharedSeriesRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedSeriesPerson.Id, _fullAccess.Id); + var chapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedChaptersPerson.Id, _fullAccess.Id); + var ageChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(lib1ChapterAgePerson.Id, _fullAccess.Id); + Assert.Equal(3, sharedSeriesRoles.Count()); + Assert.Equal(6, chapterRoles.Count()); + Assert.Single(ageChapterRoles); + + var restrictedRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedSeriesPerson.Id, _restrictedAccess.Id); + var restrictedChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedChaptersPerson.Id, _restrictedAccess.Id); + var restrictedAgePersonChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(lib1ChapterAgePerson.Id, _restrictedAccess.Id); + Assert.Equal(2, restrictedRoles.Count()); + Assert.Equal(4, restrictedChapterRoles.Count()); + Assert.Single(restrictedAgePersonChapterRoles); + + var restrictedAgeRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedSeriesPerson.Id, _restrictedAgeAccess.Id); + var restrictedAgeChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedChaptersPerson.Id, _restrictedAgeAccess.Id); + var restrictedAgeAgePersonChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(lib1ChapterAgePerson.Id, _restrictedAgeAccess.Id); + Assert.Single(restrictedAgeRoles); + Assert.Equal(2, restrictedAgeChapterRoles.Count()); + // Note: There is a potential bug here where a person in a different chapter of an age restricted series will show up + Assert.Empty(restrictedAgeAgePersonChapterRoles); + } + + [Fact] + public async Task GetPersonDtoByName() + { + await ResetDb(); + await SeedDb(); + + var allPeople = Context.Person.ToList(); + + foreach (var person in allPeople) + { + Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName(person.Name, _fullAccess.Id)); + } + + Assert.Null(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib0 Chapters Person", _restrictedAccess.Id)); + Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName("Shared Series Person", _restrictedAccess.Id)); + Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib1 Series Person", _restrictedAccess.Id)); + + Assert.Null(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib0 Chapters Person", _restrictedAgeAccess.Id)); + Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib1 Series Person", _restrictedAgeAccess.Id)); + // Note: There is a potential bug here where a person in a different chapter of an age restricted series will show up + Assert.Null(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib1 Chapter Age Person", _restrictedAgeAccess.Id)); + } + + [Fact] + public async Task GetSeriesKnownFor() + { + await ResetDb(); + await SeedDb(); + + var sharedSeriesPerson = GetPersonByName("Shared Series Person"); + var lib1SeriesPerson = GetPersonByName("Lib1 Series Person"); + + var series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(sharedSeriesPerson.Id, _fullAccess.Id); + Assert.Equal(3, series.Count()); + + series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(sharedSeriesPerson.Id, _restrictedAccess.Id); + Assert.Equal(2, series.Count()); + + series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(sharedSeriesPerson.Id, _restrictedAgeAccess.Id); + Assert.Single(series); + + series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(lib1SeriesPerson.Id, _restrictedAgeAccess.Id); + Assert.Single(series); + } + + [Fact] + public async Task GetChaptersForPersonByRole() + { + await ResetDb(); + await SeedDb(); + + var sharedChaptersPerson = GetPersonByName("Shared Chapters Person"); + + // Lib0 + var chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _fullAccess.Id, PersonRole.Colorist); + var restrictedChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAccess.Id, PersonRole.Colorist); + var restrictedAgeChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAgeAccess.Id, PersonRole.Colorist); + Assert.Single(chapters); + Assert.Empty(restrictedChapters); + Assert.Empty(restrictedAgeChapters); + + // Lib1 - age restricted series + chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _fullAccess.Id, PersonRole.Imprint); + restrictedChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAccess.Id, PersonRole.Imprint); + restrictedAgeChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAgeAccess.Id, PersonRole.Imprint); + Assert.Single(chapters); + Assert.Single(restrictedChapters); + Assert.Empty(restrictedAgeChapters); + + // Lib1 - not age restricted series + chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _fullAccess.Id, PersonRole.Team); + restrictedChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAccess.Id, PersonRole.Team); + restrictedAgeChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAgeAccess.Id, PersonRole.Team); + Assert.Single(chapters); + Assert.Single(restrictedChapters); + Assert.Single(restrictedAgeChapters); + } +} diff --git a/API.Tests/Repository/TagRepositoryTests.cs b/API.Tests/Repository/TagRepositoryTests.cs new file mode 100644 index 000000000..229082eb6 --- /dev/null +++ b/API.Tests/Repository/TagRepositoryTests.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Metadata.Browse; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Metadata; +using API.Helpers; +using API.Helpers.Builders; +using Xunit; + +namespace API.Tests.Repository; + +public class TagRepositoryTests : AbstractDbTest +{ + private AppUser _fullAccess; + private AppUser _restrictedAccess; + private AppUser _restrictedAgeAccess; + + protected override async Task ResetDb() + { + Context.Tag.RemoveRange(Context.Tag); + Context.Library.RemoveRange(Context.Library); + await Context.SaveChangesAsync(); + } + + private TestTagSet CreateTestTags() + { + return new TestTagSet + { + SharedSeriesChaptersTag = new TagBuilder("Shared Series Chapter Tag").Build(), + SharedSeriesTag = new TagBuilder("Shared Series Tag").Build(), + SharedChaptersTag = new TagBuilder("Shared Chapters Tag").Build(), + Lib0SeriesChaptersTag = new TagBuilder("Lib0 Series Chapter Tag").Build(), + Lib0SeriesTag = new TagBuilder("Lib0 Series Tag").Build(), + Lib0ChaptersTag = new TagBuilder("Lib0 Chapters Tag").Build(), + Lib1SeriesChaptersTag = new TagBuilder("Lib1 Series Chapter Tag").Build(), + Lib1SeriesTag = new TagBuilder("Lib1 Series Tag").Build(), + Lib1ChaptersTag = new TagBuilder("Lib1 Chapters Tag").Build(), + Lib1ChapterAgeTag = new TagBuilder("Lib1 Chapter Age Tag").Build() + }; + } + + private async Task SeedDbWithTags(TestTagSet tags) + { + await CreateTestUsers(); + await AddTagsToContext(tags); + await CreateLibrariesWithTags(tags); + await AssignLibrariesToUsers(); + } + + private async Task CreateTestUsers() + { + _fullAccess = new AppUserBuilder("amelia", "amelia@example.com").Build(); + _restrictedAccess = new AppUserBuilder("mila", "mila@example.com").Build(); + _restrictedAgeAccess = new AppUserBuilder("eva", "eva@example.com").Build(); + _restrictedAgeAccess.AgeRestriction = AgeRating.Teen; + _restrictedAgeAccess.AgeRestrictionIncludeUnknowns = true; + + Context.Users.Add(_fullAccess); + Context.Users.Add(_restrictedAccess); + Context.Users.Add(_restrictedAgeAccess); + await Context.SaveChangesAsync(); + } + + private async Task AddTagsToContext(TestTagSet tags) + { + var allTags = tags.GetAllTags(); + Context.Tag.AddRange(allTags); + await Context.SaveChangesAsync(); + } + + private async Task CreateLibrariesWithTags(TestTagSet tags) + { + var lib0 = new LibraryBuilder("lib0") + .WithSeries(new SeriesBuilder("lib0-s0") + .WithMetadata(new SeriesMetadata + { + Tags = [tags.SharedSeriesChaptersTag, tags.SharedSeriesTag, tags.Lib0SeriesChaptersTag, tags.Lib0SeriesTag] + }) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib0SeriesChaptersTag, tags.Lib0ChaptersTag]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag]) + .Build()) + .Build()) + .Build()) + .Build(); + + var lib1 = new LibraryBuilder("lib1") + .WithSeries(new SeriesBuilder("lib1-s0") + .WithMetadata(new SeriesMetadataBuilder() + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedSeriesTag, tags.Lib1SeriesChaptersTag, tags.Lib1SeriesTag]) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag, tags.Lib1ChapterAgeTag]) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("lib1-s1") + .WithMetadata(new SeriesMetadataBuilder() + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedSeriesTag, tags.Lib1SeriesChaptersTag, tags.Lib1SeriesTag]) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag]) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .Build()) + .Build()) + .Build(); + + Context.Library.Add(lib0); + Context.Library.Add(lib1); + await Context.SaveChangesAsync(); + } + + private async Task AssignLibrariesToUsers() + { + var lib0 = Context.Library.First(l => l.Name == "lib0"); + var lib1 = Context.Library.First(l => l.Name == "lib1"); + + _fullAccess.Libraries.Add(lib0); + _fullAccess.Libraries.Add(lib1); + _restrictedAccess.Libraries.Add(lib1); + _restrictedAgeAccess.Libraries.Add(lib1); + + await Context.SaveChangesAsync(); + } + + private static Predicate ContainsTagCheck(Tag tag) + { + return t => t.Id == tag.Id; + } + + private static void AssertTagPresent(IEnumerable tags, Tag expectedTag) + { + Assert.Contains(tags, ContainsTagCheck(expectedTag)); + } + + private static void AssertTagNotPresent(IEnumerable tags, Tag expectedTag) + { + Assert.DoesNotContain(tags, ContainsTagCheck(expectedTag)); + } + + private static BrowseTagDto GetTagDto(IEnumerable tags, Tag tag) + { + return tags.First(dto => dto.Id == tag.Id); + } + + [Fact] + public async Task GetBrowseableTag_FullAccess_ReturnsAllTagsWithCorrectCounts() + { + // Arrange + await ResetDb(); + var tags = CreateTestTags(); + await SeedDbWithTags(tags); + + // Act + var fullAccessTags = await UnitOfWork.TagRepository.GetBrowseableTag(_fullAccess.Id, new UserParams()); + + // Assert + Assert.Equal(tags.GetAllTags().Count, fullAccessTags.TotalCount); + + foreach (var tag in tags.GetAllTags()) + { + AssertTagPresent(fullAccessTags, tag); + } + + // Verify counts - 1 series lib0, 2 series lib1 = 3 total series + Assert.Equal(3, GetTagDto(fullAccessTags, tags.SharedSeriesChaptersTag).SeriesCount); + Assert.Equal(6, GetTagDto(fullAccessTags, tags.SharedSeriesChaptersTag).ChapterCount); + Assert.Equal(1, GetTagDto(fullAccessTags, tags.Lib0SeriesTag).SeriesCount); + } + + [Fact] + public async Task GetBrowseableTag_RestrictedAccess_ReturnsOnlyAccessibleTags() + { + // Arrange + await ResetDb(); + var tags = CreateTestTags(); + await SeedDbWithTags(tags); + + // Act + var restrictedAccessTags = await UnitOfWork.TagRepository.GetBrowseableTag(_restrictedAccess.Id, new UserParams()); + + // Assert - Should see: 3 shared + 4 library 1 specific = 7 tags + Assert.Equal(7, restrictedAccessTags.TotalCount); + + // Verify shared and Library 1 tags are present + AssertTagPresent(restrictedAccessTags, tags.SharedSeriesChaptersTag); + AssertTagPresent(restrictedAccessTags, tags.SharedSeriesTag); + AssertTagPresent(restrictedAccessTags, tags.SharedChaptersTag); + AssertTagPresent(restrictedAccessTags, tags.Lib1SeriesChaptersTag); + AssertTagPresent(restrictedAccessTags, tags.Lib1SeriesTag); + AssertTagPresent(restrictedAccessTags, tags.Lib1ChaptersTag); + AssertTagPresent(restrictedAccessTags, tags.Lib1ChapterAgeTag); + + // Verify Library 0 specific tags are not present + AssertTagNotPresent(restrictedAccessTags, tags.Lib0SeriesChaptersTag); + AssertTagNotPresent(restrictedAccessTags, tags.Lib0SeriesTag); + AssertTagNotPresent(restrictedAccessTags, tags.Lib0ChaptersTag); + + // Verify counts - 2 series lib1 + Assert.Equal(2, GetTagDto(restrictedAccessTags, tags.SharedSeriesChaptersTag).SeriesCount); + Assert.Equal(4, GetTagDto(restrictedAccessTags, tags.SharedSeriesChaptersTag).ChapterCount); + Assert.Equal(2, GetTagDto(restrictedAccessTags, tags.Lib1SeriesTag).SeriesCount); + Assert.Equal(4, GetTagDto(restrictedAccessTags, tags.Lib1ChaptersTag).ChapterCount); + } + + [Fact] + public async Task GetBrowseableTag_RestrictedAgeAccess_FiltersAgeRestrictedContent() + { + // Arrange + await ResetDb(); + var tags = CreateTestTags(); + await SeedDbWithTags(tags); + + // Act + var restrictedAgeAccessTags = await UnitOfWork.TagRepository.GetBrowseableTag(_restrictedAgeAccess.Id, new UserParams()); + + // Assert - Should see: 3 shared + 3 lib1 specific = 6 tags (age-restricted tag filtered out) + Assert.Equal(6, restrictedAgeAccessTags.TotalCount); + + // Verify accessible tags are present + AssertTagPresent(restrictedAgeAccessTags, tags.SharedSeriesChaptersTag); + AssertTagPresent(restrictedAgeAccessTags, tags.SharedSeriesTag); + AssertTagPresent(restrictedAgeAccessTags, tags.SharedChaptersTag); + AssertTagPresent(restrictedAgeAccessTags, tags.Lib1SeriesChaptersTag); + AssertTagPresent(restrictedAgeAccessTags, tags.Lib1SeriesTag); + AssertTagPresent(restrictedAgeAccessTags, tags.Lib1ChaptersTag); + + // Verify age-restricted tag is filtered out + AssertTagNotPresent(restrictedAgeAccessTags, tags.Lib1ChapterAgeTag); + + // Verify counts - 1 series lib1 (age-restricted series filtered out) + Assert.Equal(1, GetTagDto(restrictedAgeAccessTags, tags.SharedSeriesChaptersTag).SeriesCount); + Assert.Equal(2, GetTagDto(restrictedAgeAccessTags, tags.SharedSeriesChaptersTag).ChapterCount); + Assert.Equal(1, GetTagDto(restrictedAgeAccessTags, tags.Lib1SeriesTag).SeriesCount); + Assert.Equal(2, GetTagDto(restrictedAgeAccessTags, tags.Lib1ChaptersTag).ChapterCount); + } + + private class TestTagSet + { + public Tag SharedSeriesChaptersTag { get; set; } + public Tag SharedSeriesTag { get; set; } + public Tag SharedChaptersTag { get; set; } + public Tag Lib0SeriesChaptersTag { get; set; } + public Tag Lib0SeriesTag { get; set; } + public Tag Lib0ChaptersTag { get; set; } + public Tag Lib1SeriesChaptersTag { get; set; } + public Tag Lib1SeriesTag { get; set; } + public Tag Lib1ChaptersTag { get; set; } + public Tag Lib1ChapterAgeTag { get; set; } + + public List GetAllTags() + { + return + [ + SharedSeriesChaptersTag, SharedSeriesTag, SharedChaptersTag, + Lib0SeriesChaptersTag, Lib0SeriesTag, Lib0ChaptersTag, + Lib1SeriesChaptersTag, Lib1SeriesTag, Lib1ChaptersTag, Lib1ChapterAgeTag + ]; + } + } +} diff --git a/API.Tests/Services/ExternalMetadataServiceTests.cs b/API.Tests/Services/ExternalMetadataServiceTests.cs index 8278f3b1a..973b7c6df 100644 --- a/API.Tests/Services/ExternalMetadataServiceTests.cs +++ b/API.Tests/Services/ExternalMetadataServiceTests.cs @@ -15,6 +15,7 @@ using API.Entities.Person; using API.Helpers.Builders; using API.Services.Plus; using API.Services.Tasks.Metadata; +using API.Services.Tasks.Scanner.Parser; using API.SignalR; using Hangfire; using Microsoft.EntityFrameworkCore; @@ -881,6 +882,217 @@ public class ExternalMetadataServiceTests : AbstractDbTest } + [Fact] + public void IsSeriesCompleted_ExactMatch() + { + const string seriesName = "Test - Exact Match"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(5) + .WithTotalCount(5) + .Build()) + .Build(); + + var chapters = new List(); + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 5, Volumes = 0 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, Parser.DefaultChapterNumber); + + Assert.True(result); + } + + [Fact] + public void IsSeriesCompleted_Volumes_DecimalVolumes() + { + const string seriesName = "Test - Volume Complete"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(2) + .WithTotalCount(3) + .Build()) + .WithVolume(new VolumeBuilder("1").WithNumber(1).Build()) + .WithVolume(new VolumeBuilder("2").WithNumber(2).Build()) + .WithVolume(new VolumeBuilder("2.5").WithNumber(2.5f).Build()) + .Build(); + + var chapters = new List(); + // External metadata includes decimal volume 2.5 + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 0, Volumes = 3 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, 2); + + Assert.True(result); + Assert.Equal(3, series.Metadata.MaxCount); + Assert.Equal(3, series.Metadata.TotalCount); + } + + /// + /// This is validating that we get a completed even though we have a special chapter and AL doesn't count it + /// + [Fact] + public void IsSeriesCompleted_Volumes_HasSpecialAndDecimal_ExternalNoSpecial() + { + const string seriesName = "Test - Volume Complete"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(2) + .WithTotalCount(3) + .Build()) + .WithVolume(new VolumeBuilder("1").WithNumber(1).Build()) + .WithVolume(new VolumeBuilder("1.5").WithNumber(1.5f).Build()) + .WithVolume(new VolumeBuilder("2").WithNumber(2).Build()) + .WithVolume(new VolumeBuilder(Parser.SpecialVolume).Build()) + .Build(); + + var chapters = new List(); + // External metadata includes volume 1.5, but not the special + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 0, Volumes = 3 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, 2); + + Assert.True(result); + Assert.Equal(3, series.Metadata.MaxCount); + Assert.Equal(3, series.Metadata.TotalCount); + } + + /// + /// This unit test also illustrates the bug where you may get a false positive if you had Volumes 1,2, and 2.1. While + /// missing volume 3. With the external metadata expecting non-decimal volumes. + /// i.e. it would fail if we only had one decimal volume + /// + [Fact] + public void IsSeriesCompleted_Volumes_TooManyDecimalVolumes() + { + const string seriesName = "Test - Volume Complete"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(2) + .WithTotalCount(3) + .Build()) + .WithVolume(new VolumeBuilder("1").WithNumber(1).Build()) + .WithVolume(new VolumeBuilder("2").WithNumber(2).Build()) + .WithVolume(new VolumeBuilder("2.1").WithNumber(2.1f).Build()) + .WithVolume(new VolumeBuilder("2.2").WithNumber(2.2f).Build()) + .Build(); + + var chapters = new List(); + // External metadata includes no special or decimals. There are 3 volumes. And we're missing volume 3 + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 0, Volumes = 3 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, 2); + + Assert.False(result); + } + + [Fact] + public void IsSeriesCompleted_NoVolumes_GEQChapterCheck() + { + // We own 11 chapters, the external metadata expects 10 + const string seriesName = "Test - Chapter MaxCount, no volumes"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(11) + .WithTotalCount(10) + .Build()) + .Build(); + + var chapters = new List(); + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 10, Volumes = 0 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, Parser.DefaultChapterNumber); + + Assert.True(result); + Assert.Equal(11, series.Metadata.TotalCount); + Assert.Equal(11, series.Metadata.MaxCount); + } + + [Fact] + public void IsSeriesCompleted_NoVolumes_IncludeAllChaptersCheck() + { + const string seriesName = "Test - Chapter Count"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(7) + .WithTotalCount(10) + .Build()) + .Build(); + + var chapters = new List + { + new ChapterBuilder("0").Build(), + new ChapterBuilder("2").Build(), + new ChapterBuilder("3").Build(), + new ChapterBuilder("4").Build(), + new ChapterBuilder("5").Build(), + new ChapterBuilder("6").Build(), + new ChapterBuilder("7").Build(), + new ChapterBuilder("7.1").Build(), + new ChapterBuilder("7.2").Build(), + new ChapterBuilder("7.3").Build() + }; + // External metadata includes prologues (0) and extra's (7.X) + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 10, Volumes = 0 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, Parser.DefaultChapterNumber); + + Assert.True(result); + Assert.Equal(10, series.Metadata.TotalCount); + Assert.Equal(10, series.Metadata.MaxCount); + } + + [Fact] + public void IsSeriesCompleted_NotEnoughVolumes() + { + const string seriesName = "Test - Incomplete Volume"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(2) + .WithTotalCount(5) + .Build()) + .WithVolume(new VolumeBuilder("1").WithNumber(1).Build()) + .WithVolume(new VolumeBuilder("2").WithNumber(2).Build()) + .Build(); + + var chapters = new List(); + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 0, Volumes = 5 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, 2); + + Assert.False(result); + } + + [Fact] + public void IsSeriesCompleted_NoVolumes_NotEnoughChapters() + { + const string seriesName = "Test - Incomplete Chapter"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(5) + .WithTotalCount(8) + .Build()) + .Build(); + + var chapters = new List + { + new ChapterBuilder("1").Build(), + new ChapterBuilder("2").Build(), + new ChapterBuilder("3").Build() + }; + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 10, Volumes = 0 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, Parser.DefaultChapterNumber); + + Assert.False(result); + } + #endregion diff --git a/API/Controllers/PersonController.cs b/API/Controllers/PersonController.cs index bf3cc1814..7328ff954 100644 --- a/API/Controllers/PersonController.cs +++ b/API/Controllers/PersonController.cs @@ -185,7 +185,7 @@ public class PersonController : BaseApiController [HttpGet("series-known-for")] public async Task>> GetKnownSeries(int personId) { - return Ok(await _unitOfWork.PersonRepository.GetSeriesKnownFor(personId)); + return Ok(await _unitOfWork.PersonRepository.GetSeriesKnownFor(personId, User.GetUserId())); } /// @@ -206,6 +206,7 @@ public class PersonController : BaseApiController /// /// [HttpPost("merge")] + [Authorize("RequireAdminRole")] public async Task> MergePeople(PersonMergeDto dto) { var dst = await _unitOfWork.PersonRepository.GetPersonById(dto.DestId, PersonIncludes.All); diff --git a/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs b/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs index a3cd378b2..6704bf697 100644 --- a/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs +++ b/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs @@ -29,7 +29,9 @@ public sealed record ExternalSeriesDetailDto public DateTime? StartDate { get; set; } public DateTime? EndDate { get; set; } public int AverageScore { get; set; } + /// AniList returns the total count of unique chapters, includes 1.1 for example public int Chapters { get; set; } + /// AniList returns the total count of unique volumes, includes 1.1 for example public int Volumes { get; set; } public IList? Relations { get; set; } = []; public IList? Characters { get; set; } = []; diff --git a/API/Data/Repositories/GenreRepository.cs b/API/Data/Repositories/GenreRepository.cs index 3e645cb2e..d3baa4de6 100644 --- a/API/Data/Repositories/GenreRepository.cs +++ b/API/Data/Repositories/GenreRepository.cs @@ -173,20 +173,30 @@ public class GenreRepository : IGenreRepository { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var allLibrariesCount = await _context.Library.CountAsync(); + var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); + + var seriesIds = await _context.Series.Where(s => userLibs.Contains(s.LibraryId)).Select(s => s.Id).ToListAsync(); + var query = _context.Genre .RestrictAgainstAgeRestriction(ageRating) + .WhereIf(allLibrariesCount != userLibs.Count, + genre => genre.Chapters.Any(cp => seriesIds.Contains(cp.Volume.SeriesId)) || + genre.SeriesMetadatas.Any(sm => seriesIds.Contains(sm.SeriesId))) .Select(g => new BrowseGenreDto { Id = g.Id, Title = g.Title, SeriesCount = g.SeriesMetadatas - .Select(sm => sm.Id) + .Where(sm => allLibrariesCount == userLibs.Count || seriesIds.Contains(sm.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) .Distinct() .Count(), ChapterCount = g.Chapters - .Select(ch => ch.Id) + .Where(cp => allLibrariesCount == userLibs.Count || seriesIds.Contains(cp.Volume.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) .Distinct() - .Count() + .Count(), }) .OrderBy(g => g.Title); diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index 6954ccf03..26045c74c 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -63,7 +63,7 @@ public interface IPersonRepository Task GetPersonByNameOrAliasAsync(string name, PersonIncludes includes = PersonIncludes.Aliases); Task IsNameUnique(string name); - Task> GetSeriesKnownFor(int personId); + Task> GetSeriesKnownFor(int personId, int userId); Task> GetChaptersForPersonByRole(int personId, int userId, PersonRole role); /// /// Returns all people with a matching name, or alias @@ -179,20 +179,25 @@ public class PersonRepository : IPersonRepository public async Task> GetRolesForPersonByName(int personId, int userId) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = _context.Library.GetUserLibraries(userId); // Query roles from ChapterPeople var chapterRoles = await _context.Person .Where(p => p.Id == personId) + .SelectMany(p => p.ChapterPeople) .RestrictAgainstAgeRestriction(ageRating) - .SelectMany(p => p.ChapterPeople.Select(cp => cp.Role)) + .RestrictByLibrary(userLibs) + .Select(cp => cp.Role) .Distinct() .ToListAsync(); // Query roles from SeriesMetadataPeople var seriesRoles = await _context.Person .Where(p => p.Id == personId) + .SelectMany(p => p.SeriesMetadataPeople) .RestrictAgainstAgeRestriction(ageRating) - .SelectMany(p => p.SeriesMetadataPeople.Select(smp => smp.Role)) + .RestrictByLibrary(userLibs) + .Select(smp => smp.Role) .Distinct() .ToListAsync(); @@ -204,44 +209,53 @@ public class PersonRepository : IPersonRepository { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); - var query = CreateFilteredPersonQueryable(userId, filter, ageRating); + var query = await CreateFilteredPersonQueryable(userId, filter, ageRating); return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } - private IQueryable CreateFilteredPersonQueryable(int userId, BrowsePersonFilterDto filter, AgeRestriction ageRating) + private async Task> CreateFilteredPersonQueryable(int userId, BrowsePersonFilterDto filter, AgeRestriction ageRating) { + var allLibrariesCount = await _context.Library.CountAsync(); + var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); + + var seriesIds = await _context.Series.Where(s => userLibs.Contains(s.LibraryId)).Select(s => s.Id).ToListAsync(); + var query = _context.Person.AsNoTracking(); // Apply filtering based on statements query = BuildPersonFilterQuery(userId, filter, query); - // Apply age restriction - query = query.RestrictAgainstAgeRestriction(ageRating); + // Apply restrictions + query = query.RestrictAgainstAgeRestriction(ageRating) + .WhereIf(allLibrariesCount != userLibs.Count, + person => person.ChapterPeople.Any(cp => seriesIds.Contains(cp.Chapter.Volume.SeriesId)) || + person.SeriesMetadataPeople.Any(smp => seriesIds.Contains(smp.SeriesMetadata.SeriesId))); // Apply sorting and limiting var sortedQuery = query.SortBy(filter.SortOptions); var limitedQuery = ApplyPersonLimit(sortedQuery, filter.LimitTo); - // Project to DTO - var projectedQuery = limitedQuery.Select(p => new BrowsePersonDto + return limitedQuery.Select(p => new BrowsePersonDto { Id = p.Id, Name = p.Name, Description = p.Description, CoverImage = p.CoverImage, SeriesCount = p.SeriesMetadataPeople - .Select(smp => smp.SeriesMetadata.SeriesId) + .Select(smp => smp.SeriesMetadata) + .Where(sm => allLibrariesCount == userLibs.Count || seriesIds.Contains(sm.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) .Distinct() .Count(), ChapterCount = p.ChapterPeople - .Select(cp => cp.Chapter.Id) + .Select(chp => chp.Chapter) + .Where(ch => allLibrariesCount == userLibs.Count || seriesIds.Contains(ch.Volume.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) .Distinct() - .Count() + .Count(), }); - - return projectedQuery; } private static IQueryable BuildPersonFilterQuery(int userId, BrowsePersonFilterDto filterDto, IQueryable query) @@ -287,11 +301,13 @@ public class PersonRepository : IPersonRepository { var normalized = name.ToNormalized(); var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = _context.Library.GetUserLibraries(userId); return await _context.Person .Where(p => p.NormalizedName == normalized) .Includes(includes) .RestrictAgainstAgeRestriction(ageRating) + .RestrictByLibrary(userLibs) .ProjectTo(_mapper.ConfigurationProvider) .FirstOrDefaultAsync(); } @@ -313,14 +329,18 @@ public class PersonRepository : IPersonRepository .AnyAsync(p => p.Name == name || p.Aliases.Any(pa => pa.Alias == name))); } - public async Task> GetSeriesKnownFor(int personId) + public async Task> GetSeriesKnownFor(int personId, int userId) { - List notValidRoles = [PersonRole.Location, PersonRole.Team, PersonRole.Other, PersonRole.Publisher, PersonRole.Translator]; + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); + return await _context.Person .Where(p => p.Id == personId) - .SelectMany(p => p.SeriesMetadataPeople.Where(smp => !notValidRoles.Contains(smp.Role))) + .SelectMany(p => p.SeriesMetadataPeople) .Select(smp => smp.SeriesMetadata) .Select(sm => sm.Series) + .RestrictAgainstAgeRestriction(ageRating) + .Where(s => userLibs.Contains(s.LibraryId)) .Distinct() .OrderByDescending(s => s.ExternalSeriesMetadata.AverageExternalRating) .Take(20) @@ -331,11 +351,13 @@ public class PersonRepository : IPersonRepository public async Task> GetChaptersForPersonByRole(int personId, int userId, PersonRole role) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = _context.Library.GetUserLibraries(userId); return await _context.ChapterPeople .Where(cp => cp.PersonId == personId && cp.Role == role) .Select(cp => cp.Chapter) .RestrictAgainstAgeRestriction(ageRating) + .RestrictByLibrary(userLibs) .OrderBy(ch => ch.SortOrder) .Take(20) .ProjectTo(_mapper.ConfigurationProvider) @@ -386,27 +408,31 @@ public class PersonRepository : IPersonRepository .ToListAsync(); } - public async Task> GetAllPersonDtosAsync(int userId, PersonIncludes includes = PersonIncludes.Aliases) + public async Task> GetAllPersonDtosAsync(int userId, PersonIncludes includes = PersonIncludes.None) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = _context.Library.GetUserLibraries(userId); return await _context.Person .Includes(includes) - .OrderBy(p => p.Name) .RestrictAgainstAgeRestriction(ageRating) + .RestrictByLibrary(userLibs) + .OrderBy(p => p.Name) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } - public async Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role, PersonIncludes includes = PersonIncludes.Aliases) + public async Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role, PersonIncludes includes = PersonIncludes.None) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = _context.Library.GetUserLibraries(userId); return await _context.Person .Where(p => p.SeriesMetadataPeople.Any(smp => smp.Role == role) || p.ChapterPeople.Any(cp => cp.Role == role)) // Filter by role in both series and chapters .Includes(includes) - .OrderBy(p => p.Name) .RestrictAgainstAgeRestriction(ageRating) + .RestrictByLibrary(userLibs) + .OrderBy(p => p.Name) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } diff --git a/API/Data/Repositories/TagRepository.cs b/API/Data/Repositories/TagRepository.cs index ea39d2b0d..40d40a675 100644 --- a/API/Data/Repositories/TagRepository.cs +++ b/API/Data/Repositories/TagRepository.cs @@ -111,18 +111,28 @@ public class TagRepository : ITagRepository { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var allLibrariesCount = await _context.Library.CountAsync(); + var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); + + var seriesIds = _context.Series.Where(s => userLibs.Contains(s.LibraryId)).Select(s => s.Id); + var query = _context.Tag .RestrictAgainstAgeRestriction(ageRating) + .WhereIf(userLibs.Count != allLibrariesCount, + tag => tag.Chapters.Any(cp => seriesIds.Contains(cp.Volume.SeriesId)) || + tag.SeriesMetadatas.Any(sm => seriesIds.Contains(sm.SeriesId))) .Select(g => new BrowseTagDto { Id = g.Id, Title = g.Title, SeriesCount = g.SeriesMetadatas - .Select(sm => sm.Id) + .Where(sm => allLibrariesCount == userLibs.Count || seriesIds.Contains(sm.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) .Distinct() .Count(), ChapterCount = g.Chapters - .Select(ch => ch.Id) + .Where(ch => allLibrariesCount == userLibs.Count || seriesIds.Contains(ch.Volume.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) .Distinct() .Count() }) diff --git a/API/Extensions/EnumerableExtensions.cs b/API/Extensions/EnumerableExtensions.cs index 8beec88ca..9bc06bab4 100644 --- a/API/Extensions/EnumerableExtensions.cs +++ b/API/Extensions/EnumerableExtensions.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using API.Data.Misc; +using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; @@ -55,4 +56,16 @@ public static class EnumerableExtensions return q; } + + public static IEnumerable RestrictAgainstAgeRestriction(this IEnumerable items, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return items; + var q = items.Where(s => s.AgeRating <= restriction.AgeRating); + if (!restriction.IncludeUnknowns) + { + return q.Where(s => s.AgeRating != AgeRating.Unknown); + } + + return q; + } } diff --git a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs index 350372e5b..e0738bdf3 100644 --- a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs +++ b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs @@ -27,6 +27,19 @@ public static class RestrictByAgeExtensions return q; } + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + var q = queryable.Where(s => s.SeriesMetadata.AgeRating <= restriction.AgeRating); + + if (!restriction.IncludeUnknowns) + { + return q.Where(s => s.SeriesMetadata.AgeRating != AgeRating.Unknown); + } + + return q; + } + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { @@ -41,6 +54,19 @@ public static class RestrictByAgeExtensions return q; } + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + var q = queryable.Where(cp => cp.Chapter.Volume.Series.Metadata.AgeRating <= restriction.AgeRating); + + if (!restriction.IncludeUnknowns) + { + return q.Where(cp => cp.Chapter.Volume.Series.Metadata.AgeRating != AgeRating.Unknown); + } + + return q; + } + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { diff --git a/API/Extensions/QueryExtensions/RestrictByLibraryExtensions.cs b/API/Extensions/QueryExtensions/RestrictByLibraryExtensions.cs index e69de29bb..9ec1b8621 100644 --- a/API/Extensions/QueryExtensions/RestrictByLibraryExtensions.cs +++ b/API/Extensions/QueryExtensions/RestrictByLibraryExtensions.cs @@ -0,0 +1,31 @@ +using System.Linq; +using API.Entities; +using API.Entities.Person; + +namespace API.Extensions.QueryExtensions; + +public static class RestrictByLibraryExtensions +{ + + public static IQueryable RestrictByLibrary(this IQueryable query, IQueryable userLibs) + { + return query.Where(p => + p.ChapterPeople.Any(cp => userLibs.Contains(cp.Chapter.Volume.Series.LibraryId)) || + p.SeriesMetadataPeople.Any(sm => userLibs.Contains(sm.SeriesMetadata.Series.LibraryId))); + } + + public static IQueryable RestrictByLibrary(this IQueryable query, IQueryable userLibs) + { + return query.Where(cp => userLibs.Contains(cp.Volume.Series.LibraryId)); + } + + public static IQueryable RestrictByLibrary(this IQueryable query, IQueryable userLibs) + { + return query.Where(sm => userLibs.Contains(sm.SeriesMetadata.Series.LibraryId)); + } + + public static IQueryable RestrictByLibrary(this IQueryable query, IQueryable userLibs) + { + return query.Where(cp => userLibs.Contains(cp.Chapter.Volume.Series.LibraryId)); + } +} diff --git a/API/Helpers/Builders/ChapterBuilder.cs b/API/Helpers/Builders/ChapterBuilder.cs index f85c21595..d9976d92a 100644 --- a/API/Helpers/Builders/ChapterBuilder.cs +++ b/API/Helpers/Builders/ChapterBuilder.cs @@ -156,4 +156,24 @@ public class ChapterBuilder : IEntityBuilder return this; } + + public ChapterBuilder WithTags(IList tags) + { + _chapter.Tags ??= []; + foreach (var tag in tags) + { + _chapter.Tags.Add(tag); + } + return this; + } + + public ChapterBuilder WithGenres(IList genres) + { + _chapter.Genres ??= []; + foreach (var genre in genres) + { + _chapter.Genres.Add(genre); + } + return this; + } } diff --git a/API/Helpers/Builders/SeriesMetadataBuilder.cs b/API/Helpers/Builders/SeriesMetadataBuilder.cs index 8ceb16d95..462bc4455 100644 --- a/API/Helpers/Builders/SeriesMetadataBuilder.cs +++ b/API/Helpers/Builders/SeriesMetadataBuilder.cs @@ -108,4 +108,23 @@ public class SeriesMetadataBuilder : IEntityBuilder _seriesMetadata.TagsLocked = lockStatus; return this; } + + public SeriesMetadataBuilder WithTags(List tags, bool lockStatus = false) + { + _seriesMetadata.Tags = tags; + _seriesMetadata.TagsLocked = lockStatus; + return this; + } + + public SeriesMetadataBuilder WithMaxCount(int count) + { + _seriesMetadata.MaxCount = count; + return this; + } + + public SeriesMetadataBuilder WithTotalCount(int count) + { + _seriesMetadata.TotalCount = count; + return this; + } } diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index 1db334b91..3c8023671 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -1057,6 +1057,7 @@ public class ExternalMetadataService : IExternalMetadataService var status = DeterminePublicationStatus(series, chapters, externalMetadata); series.Metadata.PublicationStatus = status; + series.Metadata.PublicationStatusLocked = true; return true; } catch (Exception ex) @@ -1188,32 +1189,39 @@ public class ExternalMetadataService : IExternalMetadataService #region Rating - var averageCriticRating = metadata.CriticReviews.Average(r => r.Rating); - var averageUserRating = metadata.UserReviews.Average(r => r.Rating); + // C# can't make the implicit conversation here + float? averageCriticRating = metadata.CriticReviews.Count > 0 ? metadata.CriticReviews.Average(r => r.Rating) : null; + float? averageUserRating = metadata.UserReviews.Count > 0 ? metadata.UserReviews.Average(r => r.Rating) : null; var existingRatings = await _unitOfWork.ChapterRepository.GetExternalChapterRatings(chapter.Id); _unitOfWork.ExternalSeriesMetadataRepository.Remove(existingRatings); - chapter.ExternalRatings = - [ - new ExternalRating + chapter.ExternalRatings = []; + + if (averageUserRating != null) + { + chapter.ExternalRatings.Add(new ExternalRating { AverageScore = (int) averageUserRating, Provider = ScrobbleProvider.Cbr, Authority = RatingAuthority.User, ProviderUrl = metadata.IssueUrl, - }, - new ExternalRating + + }); + chapter.AverageExternalRating = averageUserRating.Value; + } + + if (averageCriticRating != null) + { + chapter.ExternalRatings.Add(new ExternalRating { AverageScore = (int) averageCriticRating, Provider = ScrobbleProvider.Cbr, Authority = RatingAuthority.Critic, ProviderUrl = metadata.IssueUrl, - }, - ]; - - chapter.AverageExternalRating = averageUserRating; + }); + } madeModification = averageUserRating > 0f || averageCriticRating > 0f || madeModification; @@ -1563,16 +1571,16 @@ public class ExternalMetadataService : IExternalMetadataService var maxVolume = (int)(nonSpecialVolumes.Count != 0 ? nonSpecialVolumes.Max(v => v.MaxNumber) : 0); var maxChapter = (int)chapters.Max(c => c.MaxNumber); - if (series.Format == MangaFormat.Epub || series.Format == MangaFormat.Pdf && chapters.Count == 1) + if (series.Format is MangaFormat.Epub or MangaFormat.Pdf && chapters.Count == 1) { series.Metadata.MaxCount = 1; } - else if (series.Metadata.TotalCount <= 1 && chapters.Count == 1 && chapters[0].IsSpecial) + else if (series.Metadata.TotalCount <= 1 && chapters is [{ IsSpecial: true }]) { series.Metadata.MaxCount = series.Metadata.TotalCount; } else if ((maxChapter == Parser.DefaultChapterNumber || maxChapter > series.Metadata.TotalCount) && - maxVolume <= series.Metadata.TotalCount) + maxVolume <= series.Metadata.TotalCount && maxVolume != Parser.DefaultChapterNumber) { series.Metadata.MaxCount = maxVolume; } @@ -1593,8 +1601,7 @@ public class ExternalMetadataService : IExternalMetadataService { status = PublicationStatus.Ended; - // Check if all volumes/chapters match the total count - if (series.Metadata.MaxCount == series.Metadata.TotalCount && series.Metadata.TotalCount > 0) + if (IsSeriesCompleted(series, chapters, externalMetadata, maxVolume)) { status = PublicationStatus.Completed; } @@ -1610,6 +1617,68 @@ public class ExternalMetadataService : IExternalMetadataService return PublicationStatus.OnGoing; } + /// + /// Returns true if the series should be marked as completed, checks loosey with chapter and series numbers. + /// Respects Specials to reach the required amount. + /// + /// + /// + /// + /// + /// + /// Updates MaxCount and TotalCount if a loosey check is used to set as completed + public static bool IsSeriesCompleted(Series series, List chapters, ExternalSeriesDetailDto externalMetadata, int maxVolumes) + { + // A series is completed if exactly the amount is found + if (series.Metadata.MaxCount == series.Metadata.TotalCount && series.Metadata.TotalCount > 0) + { + return true; + } + + // If volumes are collected, check if we reach the required volumes by including specials, and decimal volumes + // + // TODO BUG: If the series has specials, that are not included in the external count. But you do own them + // This may mark the series as completed pre-maturely + // Note: I've currently opted to keep this an equals to prevent the above bug from happening + // We *could* change this to >= in the future in case this is reported by users + // If we do; test IsSeriesCompleted_Volumes_TooManySpecials needs to be updated + if (maxVolumes != Parser.DefaultChapterNumber && externalMetadata.Volumes == series.Volumes.Count) + { + series.Metadata.MaxCount = series.Volumes.Count; + series.Metadata.TotalCount = series.Volumes.Count; + return true; + } + + // Note: If Kavita has specials, we should be lenient and ignore for the volume check + var volumeModifier = series.Volumes.Any(v => v.Name == Parser.SpecialVolume) ? 1 : 0; + var modifiedMinVolumeCount = series.Volumes.Count - volumeModifier; + if (maxVolumes != Parser.DefaultChapterNumber && externalMetadata.Volumes == modifiedMinVolumeCount) + { + series.Metadata.MaxCount = modifiedMinVolumeCount; + series.Metadata.TotalCount = modifiedMinVolumeCount; + return true; + } + + // If no volumes are collected, the series is completed if we reach or exceed the external chapters + if (maxVolumes == Parser.DefaultChapterNumber && series.Metadata.MaxCount >= externalMetadata.Chapters) + { + series.Metadata.TotalCount = series.Metadata.MaxCount; + return true; + } + + // If no volumes are collected, the series is complete if we reach or exceed the external chapters while including + // prologues, and extra chapters + if (maxVolumes == Parser.DefaultChapterNumber && chapters.Count >= externalMetadata.Chapters) + { + series.Metadata.TotalCount = chapters.Count; + series.Metadata.MaxCount = chapters.Count; + return true; + } + + + return false; + } + private static Dictionary> ApplyFieldMappings(IEnumerable values, MetadataFieldType sourceType, List mappings) { var result = new Dictionary>(); diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index e73d82b1f..575f89b3b 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -215,9 +215,9 @@ public class TaskScheduler : ITaskScheduler RecurringJob.AddOrUpdate(LicenseCheckId, () => _licenseService.GetLicenseInfo(false), LicenseService.Cron, RecurringJobOptions); - // KavitaPlus Scrobbling (every hour) + // KavitaPlus Scrobbling (every hour) - randomise minutes to spread requests out for K+ RecurringJob.AddOrUpdate(ProcessScrobblingEventsId, () => _scrobblingService.ProcessUpdatesSinceLastSync(), - "0 */1 * * *", RecurringJobOptions); + Cron.Hourly(Rnd.Next(0, 60)), RecurringJobOptions); RecurringJob.AddOrUpdate(ProcessProcessedScrobblingEventsId, () => _scrobblingService.ClearProcessedEvents(), Cron.Daily, RecurringJobOptions); diff --git a/UI/Web/src/app/_services/nav.service.ts b/UI/Web/src/app/_services/nav.service.ts index 65d9fca17..0aad76ef7 100644 --- a/UI/Web/src/app/_services/nav.service.ts +++ b/UI/Web/src/app/_services/nav.service.ts @@ -9,6 +9,24 @@ import {AccountService} from "./account.service"; import {map} from "rxjs/operators"; import {NavigationEnd, Router} from "@angular/router"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component"; +import {WikiLink} from "../_models/wiki"; + +/** + * NavItem used to construct the dropdown or NavLinkModal on mobile + * Priority construction + * @param routerLink A link to a page on the web app, takes priority + * @param fragment Optional fragment for routerLink + * @param href A link to an external page, must set noopener noreferrer + * @param click Callback, lowest priority. Should only be used if routerLink and href or not set + */ +interface NavItem { + transLocoKey: string; + href?: string; + fragment?: string; + routerLink?: string; + click?: () => void; +} @Injectable({ providedIn: 'root' @@ -21,6 +39,33 @@ export class NavService { public localStorageSideNavKey = 'kavita--sidenav--expanded'; + public navItems: NavItem[] = [ + { + transLocoKey: 'all-filters', + routerLink: '/all-filters/', + }, + { + transLocoKey: 'browse-genres', + routerLink: '/browse/genres', + }, + { + transLocoKey: 'browse-tags', + routerLink: '/browse/tags', + }, + { + transLocoKey: 'announcements', + routerLink: '/announcements/', + }, + { + transLocoKey: 'help', + href: WikiLink.Guides, + }, + { + transLocoKey: 'logout', + click: () => this.logout(), + } + ] + private navbarVisibleSource = new ReplaySubject(1); /** * If the top Nav bar is rendered or not @@ -127,6 +172,13 @@ export class NavService { }, 10); } + logout() { + this.accountService.logout(); + this.hideNavBar(); + this.hideSideNav(); + this.router.navigateByUrl('/login'); + } + /** * Shows the side nav. When being visible, the side nav will automatically return to previous collapsed state. */ 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 96fd71b95..f5f4e1e26 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 @@ -43,7 +43,7 @@ [sorts]="[{prop: 'createdUtc', dir: 'desc'}]" > - + } diff --git a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts index fd4af01f0..11b1f3307 100644 --- a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts +++ b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts @@ -134,13 +134,6 @@ export class NavHeaderComponent implements OnInit { this.cdRef.markForCheck(); } - logout() { - this.accountService.logout(); - this.navService.hideNavBar(); - this.navService.hideSideNav(); - this.router.navigateByUrl('/login'); - } - moveFocus() { this.document.getElementById('content')?.focus(); } @@ -253,7 +246,6 @@ export class NavHeaderComponent implements OnInit { openLinkSelectionMenu() { const ref = this.modalService.open(NavLinkModalComponent, {fullscreen: 'sm'}); - ref.componentInstance.logoutFn = this.logout.bind(this); } } diff --git a/UI/Web/src/app/nav/_components/nav-link-modal/nav-link-modal.component.html b/UI/Web/src/app/nav/_components/nav-link-modal/nav-link-modal.component.html index 6d94f0ed5..48c93f410 100644 --- a/UI/Web/src/app/nav/_components/nav-link-modal/nav-link-modal.component.html +++ b/UI/Web/src/app/nav/_components/nav-link-modal/nav-link-modal.component.html @@ -6,21 +6,22 @@
    public bool EnableMetadata { get; set; } = true; + /// + /// Should Kavita remove sort articles "The" for the sort name + /// + public bool RemovePrefixForSortName { get; set; } = false; } diff --git a/API/DTOs/UpdateLibraryDto.cs b/API/DTOs/UpdateLibraryDto.cs index 68d2417ec..d7f314208 100644 --- a/API/DTOs/UpdateLibraryDto.cs +++ b/API/DTOs/UpdateLibraryDto.cs @@ -30,6 +30,8 @@ public sealed record UpdateLibraryDto public bool AllowMetadataMatching { get; init; } [Required] public bool EnableMetadata { get; init; } + [Required] + public bool RemovePrefixForSortName { get; init; } /// /// What types of files to allow the scanner to pickup /// diff --git a/API/Data/Migrations/20250629153840_LibraryRemoveSortPrefix.Designer.cs b/API/Data/Migrations/20250629153840_LibraryRemoveSortPrefix.Designer.cs new file mode 100644 index 000000000..165663f3d --- /dev/null +++ b/API/Data/Migrations/20250629153840_LibraryRemoveSortPrefix.Designer.cs @@ -0,0 +1,3724 @@ +// +using System; +using System.Collections.Generic; +using API.Data; +using API.Entities.MetadataMatching; +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("20250629153840_LibraryRemoveSortPrefix")] + partial class LibraryRemoveSortPrefix + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + 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("HasRunScrobbleEventGeneration") + .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("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.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.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.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.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("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("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("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("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("RemovePrefixForSortName") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("KoreaderHash") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("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("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.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.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.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.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.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.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.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("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + 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("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.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/20250629153840_LibraryRemoveSortPrefix.cs b/API/Data/Migrations/20250629153840_LibraryRemoveSortPrefix.cs new file mode 100644 index 000000000..4800cf3fa --- /dev/null +++ b/API/Data/Migrations/20250629153840_LibraryRemoveSortPrefix.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class LibraryRemoveSortPrefix : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RemovePrefixForSortName", + table: "Library", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RemovePrefixForSortName", + table: "Library"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 106a86b4a..62d1fb1ef 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -1341,6 +1341,9 @@ namespace API.Data.Migrations b.Property("PrimaryColor") .HasColumnType("TEXT"); + b.Property("RemovePrefixForSortName") + .HasColumnType("INTEGER"); + b.Property("SecondaryColor") .HasColumnType("TEXT"); diff --git a/API/Entities/Library.cs b/API/Entities/Library.cs index 8dc386298..4a48fed99 100644 --- a/API/Entities/Library.cs +++ b/API/Entities/Library.cs @@ -52,6 +52,10 @@ public class Library : IEntityDate, IHasCoverImage /// Should Kavita read metadata files from the library ///
    public bool EnableMetadata { get; set; } = true; + /// + /// Should Kavita remove sort articles "The" for the sort name + /// + public bool RemovePrefixForSortName { get; set; } = false; public DateTime Created { get; set; } diff --git a/API/Helpers/BookSortTitlePrefixHelper.cs b/API/Helpers/BookSortTitlePrefixHelper.cs new file mode 100644 index 000000000..c92df5d65 --- /dev/null +++ b/API/Helpers/BookSortTitlePrefixHelper.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace API.Helpers; + +/// +/// Responsible for parsing book titles "The man on the street" and removing the prefix -> "man on the street". +/// +/// This code is performance sensitive +public static class BookSortTitlePrefixHelper +{ + private static readonly Dictionary PrefixLookup; + private static readonly Dictionary> PrefixesByFirstChar; + + static BookSortTitlePrefixHelper() + { + var prefixes = new[] + { + // English + "the", "a", "an", + // Spanish + "el", "la", "los", "las", "un", "una", "unos", "unas", + // French + "le", "la", "les", "un", "une", "des", + // German + "der", "die", "das", "den", "dem", "ein", "eine", "einen", "einer", + // Italian + "il", "lo", "la", "gli", "le", "un", "uno", "una", + // Portuguese + "o", "a", "os", "as", "um", "uma", "uns", "umas", + // Russian (transliterated common ones) + "в", "на", "с", "к", "от", "для", + }; + + // Build lookup structures + PrefixLookup = new Dictionary(prefixes.Length, StringComparer.OrdinalIgnoreCase); + PrefixesByFirstChar = new Dictionary>(); + + foreach (var prefix in prefixes) + { + PrefixLookup[prefix] = 1; + + var firstChar = char.ToLowerInvariant(prefix[0]); + if (!PrefixesByFirstChar.TryGetValue(firstChar, out var list)) + { + list = []; + PrefixesByFirstChar[firstChar] = list; + } + list.Add(prefix); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ReadOnlySpan GetSortTitle(ReadOnlySpan title) + { + if (title.IsEmpty) return title; + + // Fast detection of script type by first character + var firstChar = title[0]; + + // CJK Unicode ranges - no processing needed for most cases + if ((firstChar >= 0x4E00 && firstChar <= 0x9FFF) || // CJK Unified + (firstChar >= 0x3040 && firstChar <= 0x309F) || // Hiragana + (firstChar >= 0x30A0 && firstChar <= 0x30FF)) // Katakana + { + return title; + } + + var firstSpaceIndex = title.IndexOf(' '); + if (firstSpaceIndex <= 0) return title; + + var potentialPrefix = title.Slice(0, firstSpaceIndex); + + // Fast path: check if first character could match any prefix + firstChar = char.ToLowerInvariant(potentialPrefix[0]); + if (!PrefixesByFirstChar.ContainsKey(firstChar)) + return title; + + // Only do the expensive lookup if first character matches + if (PrefixLookup.ContainsKey(potentialPrefix.ToString())) + { + var remainder = title.Slice(firstSpaceIndex + 1); + return remainder.IsEmpty ? title : remainder; + } + + return title; + } + + /// + /// Removes the sort prefix + /// + /// + /// + public static string GetSortTitle(string title) + { + var result = GetSortTitle(title.AsSpan()); + + return result.ToString(); + } +} diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index cf3a9f3fb..307408adb 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -126,13 +126,17 @@ public class ProcessSeries : IProcessSeries series.Format = firstParsedInfo.Format; } + var removePrefix = library.RemovePrefixForSortName; + var sortName = removePrefix ? BookSortTitlePrefixHelper.GetSortTitle(series.Name) : series.Name; + if (string.IsNullOrEmpty(series.SortName)) { - series.SortName = series.Name; + series.SortName = sortName; } + if (!series.SortNameLocked) { - series.SortName = series.Name; + series.SortName = sortName; if (!string.IsNullOrEmpty(firstParsedInfo.SeriesSort)) { series.SortName = firstParsedInfo.SeriesSort; diff --git a/UI/Web/src/app/_models/library/library.ts b/UI/Web/src/app/_models/library/library.ts index 0e7d90ee2..bcbf9b447 100644 --- a/UI/Web/src/app/_models/library/library.ts +++ b/UI/Web/src/app/_models/library/library.ts @@ -32,6 +32,7 @@ export interface Library { allowScrobbling: boolean; allowMetadataMatching: boolean; enableMetadata: boolean; + removePrefixForSortName: boolean; collapseSeriesRelationships: boolean; libraryFileTypes: Array; excludePatterns: Array; diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html index ff97fcbb0..e8a3bafeb 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html @@ -127,6 +127,16 @@ +
    + + +
    + +
    +
    +
    +
    +
    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 d0fed5c81..9331376ef 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 @@ -115,6 +115,7 @@ export class LibrarySettingsModalComponent implements OnInit { allowMetadataMatching: new FormControl(true, { nonNullable: true, validators: [] }), collapseSeriesRelationships: new FormControl(false, { nonNullable: true, validators: [] }), enableMetadata: new FormControl(true, { nonNullable: true, validators: [] }), // required validator doesn't check value, just if true + removePrefixForSortName: new FormControl(false, { nonNullable: true, validators: [] }), }); selectedFolders: string[] = []; @@ -273,7 +274,8 @@ export class LibrarySettingsModalComponent implements OnInit { this.libraryForm.get('allowScrobbling')?.setValue(this.IsKavitaPlusEligible ? this.library.allowScrobbling : false); this.libraryForm.get('allowMetadataMatching')?.setValue(this.IsMetadataDownloadEligible ? this.library.allowMetadataMatching : false); this.libraryForm.get('excludePatterns')?.setValue(this.excludePatterns ? this.library.excludePatterns : false); - this.libraryForm.get('enableMetadata')?.setValue(this.library.enableMetadata, true); + this.libraryForm.get('enableMetadata')?.setValue(this.library.enableMetadata); + this.libraryForm.get('removePrefixForSortName')?.setValue(this.library.removePrefixForSortName); this.selectedFolders = this.library.folders; this.madeChanges = false; diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index c6b8c823f..33bde5e0e 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -1131,6 +1131,8 @@ "include-in-search-tooltip": "Should series and any derived information (genres, people, files) from the library be included in search results.", "enable-metadata-label": "Enable Metadata (ComicInfo/Epub/PDF)", "enable-metadata-tooltip": "Allow Kavita to read metadata files which override filename parsing.", + "remove-prefix-for-sortname-label": "Remove common prefixes for Sort Name", + "remove-prefix-for-sortname-tooltip": "Kavita will remove common prefixes like 'The', 'A', 'An' from titles for sort name. Does not override set metadata.", "force-scan": "Force Scan", "force-scan-tooltip": "This will force a scan on the library, treating like a fresh scan", "reset": "{{common.reset}}", From 76fd7ab4ce2b474fd1ed94281e9217f932975734 Mon Sep 17 00:00:00 2001 From: majora2007 Date: Sat, 5 Jul 2025 22:18:52 +0000 Subject: [PATCH 56/57] Bump versions by dotnet-bump-version. --- Kavita.Common/Kavita.Common.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index c2ba1669d..c7dd0ab94 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -3,7 +3,7 @@ net9.0 kavitareader.com Kavita - 0.8.7.0 + 0.8.7.1 en true @@ -20,4 +20,4 @@ - + \ No newline at end of file From ef2640b5fc2e7e2836cb30b8f8bbbf9025774d57 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 5 Jul 2025 22:20:01 +0000 Subject: [PATCH 57/57] Update OpenAPI documentation --- openapi.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/openapi.json b/openapi.json index e9a3620e9..3e4b797cb 100644 --- a/openapi.json +++ b/openapi.json @@ -21371,6 +21371,10 @@ "type": "boolean", "description": "Should Kavita read metadata files from the library" }, + "removePrefixForSortName": { + "type": "boolean", + "description": "Should Kavita remove sort articles \"The\" for the sort name" + }, "created": { "type": "string", "format": "date-time" @@ -21533,6 +21537,10 @@ "enableMetadata": { "type": "boolean", "description": "Allow Kavita to read metadata (ComicInfo.xml, Epub, PDF)" + }, + "removePrefixForSortName": { + "type": "boolean", + "description": "Should Kavita remove sort articles \"The\" for the sort name" } }, "additionalProperties": false @@ -26438,6 +26446,7 @@ "manageCollections", "manageReadingLists", "name", + "removePrefixForSortName", "type" ], "type": "object", @@ -26492,6 +26501,9 @@ "enableMetadata": { "type": "boolean" }, + "removePrefixForSortName": { + "type": "boolean" + }, "fileGroupTypes": { "type": "array", "items": {