diff --git a/API.Tests/Services/ExternalMetadataServiceTests.cs b/API.Tests/Services/ExternalMetadataServiceTests.cs index 973b7c6df..bcde002b6 100644 --- a/API.Tests/Services/ExternalMetadataServiceTests.cs +++ b/API.Tests/Services/ExternalMetadataServiceTests.cs @@ -1288,6 +1288,21 @@ public class ExternalMetadataServiceTests : AbstractDbTest Assert.Equal(AgeRating.Teen, postSeries.Metadata.AgeRating); } + [Fact] + public void AgeRating_NormalizedMapping() + { + var tags = new List { "tAg$'1", "tag2" }; + var mappings = new Dictionary() + { + ["tag1"] = AgeRating.Teen, + }; + + Assert.Equal(AgeRating.Teen, ExternalMetadataService.DetermineAgeRating(tags, mappings)); + + mappings.Add("tag2", AgeRating.AdultsOnly); + Assert.Equal(AgeRating.AdultsOnly, ExternalMetadataService.DetermineAgeRating(tags, mappings)); + } + #endregion #region Genres @@ -1600,6 +1615,100 @@ public class ExternalMetadataServiceTests : AbstractDbTest Assert.Equal(["Boxing"], postSeries.Metadata.Tags.Select(t => t.Title)); } + #endregion + + #region FieldMappings + + [Fact] + public void GenerateGenreAndTagLists_Normalized_Mappings() + { + var settings = new MetadataSettingsDto + { + EnableExtendedMetadataProcessing = true, + Whitelist = [], + Blacklist = [], + FieldMappings = [ + new MetadataFieldMappingDto + { + SourceType = MetadataFieldType.Tag, + SourceValue = "Girls love", + DestinationType = MetadataFieldType.Genre, + DestinationValue = "Yuri", + ExcludeFromSource = false, + }, + new MetadataFieldMappingDto + { + SourceType = MetadataFieldType.Tag, + SourceValue = "Girls love", + DestinationType = MetadataFieldType.Genre, + DestinationValue = "Romance", + ExcludeFromSource = false, + }, + new MetadataFieldMappingDto + { + SourceType = MetadataFieldType.Genre, + SourceValue = "WW2", + DestinationType = MetadataFieldType.Genre, + DestinationValue = "War", + ExcludeFromSource = true, + }, + ], + }; + + var tags = new List { "Girl's Love", "Unrelated tag" }; + var genres = new List { "Ww2", "Unrelated genre" }; + + ExternalMetadataService.GenerateExternalGenreAndTagsList(genres, tags, settings, + out var finalTags, out var finalGenres); + + Assert.Contains("Unrelated tag", finalTags); + + Assert.Contains("Yuri", finalGenres); + Assert.Contains("Romance", finalGenres); + Assert.Contains("Unrelated genre", finalGenres); + Assert.DoesNotContain("Ww2", finalGenres); + } + + [Fact] + public void GenerateGenreAndTagLists_RemoveIfAnyRemoves() + { + var settings = new MetadataSettingsDto + { + EnableExtendedMetadataProcessing = true, + Whitelist = [], + Blacklist = [], + FieldMappings = [ + new MetadataFieldMappingDto + { + SourceType = MetadataFieldType.Tag, + SourceValue = "Girls love", + DestinationType = MetadataFieldType.Genre, + DestinationValue = "Yuri", + ExcludeFromSource = false, + }, + new MetadataFieldMappingDto + { + SourceType = MetadataFieldType.Tag, + SourceValue = "Girls love", + DestinationType = MetadataFieldType.Genre, + DestinationValue = "Romance", + ExcludeFromSource = true, + }, + ], + }; + + var tags = new List { "Girl's Love"}; + var genres = new List(); + + ExternalMetadataService.GenerateExternalGenreAndTagsList(genres, tags, settings, + out var finalTags, out var finalGenres); + + Assert.Contains("Yuri", finalGenres); + Assert.Contains("Romance", finalGenres); + Assert.DoesNotContain("Girls Love", finalGenres); + } + + #endregion #region People - Writers/Artists diff --git a/API.Tests/Services/SettingsServiceTests.cs b/API.Tests/Services/SettingsServiceTests.cs index a3c6b67b8..074237d2f 100644 --- a/API.Tests/Services/SettingsServiceTests.cs +++ b/API.Tests/Services/SettingsServiceTests.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; using System.IO.Abstractions; +using System.Linq; using System.Threading.Tasks; using API.Data; using API.Data.Repositories; +using API.DTOs; using API.DTOs.KavitaPlus.Metadata; using API.Entities; using API.Entities.Enums; @@ -20,6 +22,11 @@ public class SettingsServiceTests private readonly ISettingsService _settingsService; private readonly IUnitOfWork _mockUnitOfWork; + private const string DefaultAgeKey = "default_age"; + private const string DefaultFieldSource = "default_source"; + private readonly static AgeRating DefaultAgeRating = AgeRating.Everyone; + private readonly static MetadataFieldType DefaultSourceField = MetadataFieldType.Genre; + public SettingsServiceTests() { var ds = new DirectoryService(Substitute.For>(), new FileSystem()); @@ -30,6 +37,192 @@ public class SettingsServiceTests Substitute.For>()); } + #region ImportMetadataSettings + + [Fact] + public async Task ImportFieldMappings_ReplaceMode() + { + var existingSettings = CreateDefaultMetadataSettingsDto(); + var newSettings = new MetadataSettingsDto + { + Whitelist = ["new_whitelist_item"], + Blacklist = ["new_blacklist_item"], + AgeRatingMappings = new Dictionary { ["new_age"] = AgeRating.R18Plus }, + FieldMappings = + [ + new MetadataFieldMappingDto { Id = 10, SourceValue = "new_source", SourceType = MetadataFieldType.Genre, DestinationValue = "new_dest", DestinationType = MetadataFieldType.Tag } + ], + }; + + var importSettings = new ImportSettingsDto + { + ImportMode = ImportMode.Replace, + Whitelist = true, + Blacklist = true, + AgeRatings = true, + FieldMappings = true, + Resolution = ConflictResolution.Manual, + AgeRatingConflictResolutions = [], + }; + + var settingsRepo = Substitute.For(); + settingsRepo.GetMetadataSettingDto().Returns(existingSettings); + _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); + + var result = await _settingsService.ImportFieldMappings(newSettings, importSettings); + + Assert.True(result.Success); + Assert.Empty(result.AgeRatingConflicts); + + Assert.Equal(existingSettings.Whitelist, newSettings.Whitelist); + Assert.Equal(existingSettings.Blacklist, newSettings.Blacklist); + Assert.Equal(existingSettings.AgeRatingMappings, newSettings.AgeRatingMappings); + Assert.Equal(existingSettings.FieldMappings, newSettings.FieldMappings); + } + + [Fact] + public async Task ImportFieldMappings_MergeMode_WithNoConflicts() + { + var existingSettingsDto = CreateDefaultMetadataSettingsDto(); + var existingSettings = CreateDefaultMetadataSettings(); + + var newSettings = new MetadataSettingsDto + { + Whitelist = ["new_whitelist_item"], + Blacklist = ["new_blacklist_item"], + AgeRatingMappings = new Dictionary { ["new_age"] = AgeRating.R18Plus }, + FieldMappings = + [ + new MetadataFieldMappingDto { Id = 10, SourceValue = "new_source", SourceType = MetadataFieldType.Genre, DestinationValue = "new_dest", DestinationType = MetadataFieldType.Tag }, + ], + }; + + var importSettings = new ImportSettingsDto + { + ImportMode = ImportMode.Merge, + Whitelist = true, + Blacklist = true, + AgeRatings = true, + FieldMappings = true, + Resolution = ConflictResolution.Manual, + AgeRatingConflictResolutions = [], + }; + + var settingsRepo = Substitute.For(); + settingsRepo.GetMetadataSettingDto().Returns(existingSettingsDto); + settingsRepo.GetMetadataSettings().Returns(existingSettings); + _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); + + var result = await _settingsService.ImportFieldMappings(newSettings, importSettings); + + Assert.True(result.Success); + Assert.Empty(result.AgeRatingConflicts); + + Assert.Contains("default_white", existingSettingsDto.Whitelist); + Assert.Contains("new_whitelist_item", existingSettingsDto.Whitelist); + Assert.Contains("default_black", existingSettingsDto.Blacklist); + Assert.Contains("new_blacklist_item", existingSettingsDto.Blacklist); + Assert.Equal(2, existingSettingsDto.AgeRatingMappings.Count); + Assert.Equal(2, existingSettingsDto.FieldMappings.Count); + } + + [Fact] + public async Task ImportFieldMappings_MergeMode_UseConfiguredOverrides() + { + var existingSettingsDto = CreateDefaultMetadataSettingsDto(); + var existingSettings = CreateDefaultMetadataSettings(); + + var newSettings = new MetadataSettingsDto + { + Whitelist = [], + Blacklist = [], + AgeRatingMappings = new Dictionary { [DefaultAgeKey] = AgeRating.R18Plus }, + FieldMappings = + [ + new MetadataFieldMappingDto + { + Id = 20, + SourceValue = DefaultFieldSource, + SourceType = DefaultSourceField, + DestinationValue = "different_dest", + DestinationType = MetadataFieldType.Genre, + } + ], + }; + + var importSettings = new ImportSettingsDto + { + ImportMode = ImportMode.Merge, + Whitelist = false, + Blacklist = false, + AgeRatings = true, + FieldMappings = true, + Resolution = ConflictResolution.Manual, + AgeRatingConflictResolutions = new Dictionary { [DefaultAgeKey] = ConflictResolution.Replace }, + }; + + var settingsRepo = Substitute.For(); + settingsRepo.GetMetadataSettingDto().Returns(existingSettingsDto); + settingsRepo.GetMetadataSettings().Returns(existingSettings); + _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); + + var result = await _settingsService.ImportFieldMappings(newSettings, importSettings); + + Assert.True(result.Success); + Assert.Empty(result.AgeRatingConflicts); + + Assert.Equal(AgeRating.R18Plus, existingSettingsDto.AgeRatingMappings[DefaultAgeKey]); + } + + [Fact] + public async Task ImportFieldMappings_MergeMode_SkipIdenticalMappings() + { + var existingSettingsDto = CreateDefaultMetadataSettingsDto(); + var existingSettings = CreateDefaultMetadataSettings(); + + var newSettings = new MetadataSettingsDto + { + Whitelist = [], + Blacklist = [], + AgeRatingMappings = new Dictionary { ["existing_age"] = AgeRating.Mature }, // Same value + FieldMappings = + [ + new MetadataFieldMappingDto + { + Id = 20, + SourceValue = "existing_source", + SourceType = MetadataFieldType.Genre, + DestinationValue = "existing_dest", // Same destination + DestinationType = MetadataFieldType.Tag // Same destination type + } + ], + }; + + var importSettings = new ImportSettingsDto + { + ImportMode = ImportMode.Merge, + Whitelist = false, + Blacklist = false, + AgeRatings = true, + FieldMappings = true, + Resolution = ConflictResolution.Manual, + AgeRatingConflictResolutions = [], + }; + + var settingsRepo = Substitute.For(); + settingsRepo.GetMetadataSettingDto().Returns(existingSettingsDto); + settingsRepo.GetMetadataSettings().Returns(existingSettings); + _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); + + var result = await _settingsService.ImportFieldMappings(newSettings, importSettings); + + Assert.True(result.Success); + Assert.Empty(result.AgeRatingConflicts); + } + + + #endregion + #region UpdateMetadataSettings [Fact] @@ -289,4 +482,46 @@ public class SettingsServiceTests } #endregion + + private MetadataSettingsDto CreateDefaultMetadataSettingsDto() + { + return new MetadataSettingsDto + { + Whitelist = ["default_white"], + Blacklist = ["default_black"], + AgeRatingMappings = new Dictionary { ["default_age"] = AgeRating.Everyone }, + FieldMappings = + [ + new MetadataFieldMappingDto + { + Id = 1, + SourceValue = "default_source", + SourceType = MetadataFieldType.Genre, + DestinationValue = "default_dest", + DestinationType = MetadataFieldType.Tag + }, + ], + }; + } + + private MetadataSettings CreateDefaultMetadataSettings() + { + return new MetadataSettings + { + Whitelist = ["default_white"], + Blacklist = ["default_black"], + AgeRatingMappings = new Dictionary { [DefaultAgeKey] = DefaultAgeRating }, + FieldMappings = + [ + new MetadataFieldMapping + { + Id = 1, + SourceValue = DefaultFieldSource, + SourceType = DefaultSourceField, + DestinationValue = "default_dest", + DestinationType = MetadataFieldType.Tag + }, + ], + }; + } } diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 0610c8705..71153738a 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Net; using System.Threading.Tasks; using API.Data; +using API.DTOs; using API.DTOs.Email; using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Settings; @@ -253,4 +254,24 @@ public class SettingsController : BaseApiController return BadRequest(ex.Message); } } + + /// + /// Import field mappings + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("import-field-mappings")] + public async Task> ImportFieldMappings([FromBody] ImportFieldMappingsDto dto) + { + try + { + return Ok(await _settingsService.ImportFieldMappings(dto.Data, dto.Settings)); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue importing field mappings"); + return BadRequest(ex.Message); + } + } + } diff --git a/API/DTOs/ImportFieldMappings.cs b/API/DTOs/ImportFieldMappings.cs new file mode 100644 index 000000000..095bad905 --- /dev/null +++ b/API/DTOs/ImportFieldMappings.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using API.DTOs.KavitaPlus.Metadata; + +namespace API.DTOs; + +/// +/// How Kavita should import the new settings +/// +public enum ImportMode +{ + [Description("Replace")] + Replace = 0, + [Description("Merge")] + Merge = 1, +} + +/// +/// How Kavita should resolve conflicts +/// +public enum ConflictResolution +{ + /// + /// Require the user to override the default + /// + [Description("Manual")] + Manual = 0, + /// + /// Keep current value + /// + [Description("Keep")] + Keep = 1, + /// + /// Replace with imported value + /// + [Description("Replace")] + Replace = 2, +} + +public sealed record ImportSettingsDto +{ + /// + /// How Kavita should import the new settings + /// + public ImportMode ImportMode { get; init; } + /// + /// Default conflict resolution, override with and + /// + public ConflictResolution Resolution { get; init; } + /// + /// Import + /// + public bool Whitelist { get; init; } + /// + /// Import + /// + public bool Blacklist { get; init; } + /// + /// Import + /// + public bool AgeRatings { get; init; } + /// + /// Import + /// + public bool FieldMappings { get; init; } + + /// + /// Override the for specific age ratings + /// + /// Key is the tag + public Dictionary AgeRatingConflictResolutions { get; init; } +} + +public sealed record FieldMappingsImportResultDto +{ + public bool Success { get; init; } + /// + /// Only present if is true + /// + public MetadataSettingsDto ResultingMetadataSettings { get; init; } + /// + /// Keys of the conflicting age ratings mappings + /// + public List AgeRatingConflicts { get; init; } +} diff --git a/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs b/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs index e9f6614bc..5c1c74453 100644 --- a/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs +++ b/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; using API.Entities.MetadataMatching; @@ -7,13 +8,18 @@ using NotImplementedException = System.NotImplementedException; namespace API.DTOs.KavitaPlus.Metadata; -public sealed record MetadataSettingsDto +public sealed record MetadataSettingsDto: FieldMappingsDto { /// /// If writing any sort of metadata from upstream (AniList, Hardcover) source is allowed /// public bool Enabled { get; set; } + /// + /// Enable processing of metadata outside K+; e.g. disk and API + /// + public bool EnableExtendedMetadataProcessing { get; set; } + /// /// Allow the Summary to be written /// @@ -75,28 +81,11 @@ public sealed record MetadataSettingsDto /// public bool FirstLastPeopleNaming { get; set; } - /// - /// Any Genres or Tags that if present, will trigger an Age Rating Override. Highest rating will be prioritized for matching. - /// - public Dictionary AgeRatingMappings { get; set; } - - /// - /// A list of rules that allow mapping a genre/tag to another genre/tag - /// - public List FieldMappings { get; set; } /// /// A list of overrides that will enable writing to locked fields /// public List Overrides { get; set; } - /// - /// Do not allow any Genre/Tag in this list to be written to Kavita - /// - public List Blacklist { get; set; } - /// - /// Only allow these Tags to be written to Kavita - /// - public List Whitelist { get; set; } /// /// Which Roles to allow metadata downloading for /// @@ -123,3 +112,30 @@ public sealed record MetadataSettingsDto return PersonRoles.Contains(character); } } + +/// +/// Decoupled from to allow reuse without requiring the full metadata settings in +/// +/// +public record FieldMappingsDto +{ + /// + /// Do not allow any Genre/Tag in this list to be written to Kavita + /// + public List Blacklist { get; set; } + + /// + /// Only allow these Tags to be written to Kavita + /// + public List Whitelist { get; set; } + + /// + /// Any Genres or Tags that if present, will trigger an Age Rating Override. Highest rating will be prioritized for matching. + /// + public Dictionary AgeRatingMappings { get; set; } + + /// + /// A list of rules that allow mapping a genre/tag to another genre/tag + /// + public List FieldMappings { get; set; } +} diff --git a/API/DTOs/Settings/ImportFieldMappingsDto.cs b/API/DTOs/Settings/ImportFieldMappingsDto.cs new file mode 100644 index 000000000..2699292b2 --- /dev/null +++ b/API/DTOs/Settings/ImportFieldMappingsDto.cs @@ -0,0 +1,15 @@ +using API.DTOs.KavitaPlus.Metadata; + +namespace API.DTOs.Settings; + +public sealed record ImportFieldMappingsDto +{ + /// + /// Import settings + /// + public ImportSettingsDto Settings { get; init; } + /// + /// Data to import + /// + public FieldMappingsDto Data { get; init; } +} diff --git a/API/Data/ManualMigrations/v0.8.8/ManualMigrateEnableMetadataMatchingDefault.cs b/API/Data/ManualMigrations/v0.8.8/ManualMigrateEnableMetadataMatchingDefault.cs new file mode 100644 index 000000000..5bb8aeb94 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.8/ManualMigrateEnableMetadataMatchingDefault.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading.Tasks; +using API.Entities.History; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.8 - If Kavita+ users had Metadata Matching settings already, ensure the new non-Kavita+ system is enabled to match +/// existing experience +/// +public static class ManualMigrateEnableMetadataMatchingDefault +{ + public static async Task Migrate(DataContext context, IUnitOfWork unitOfWork, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateEnableMetadataMatchingDefault")) + { + return; + } + + logger.LogCritical("Running ManualMigrateEnableMetadataMatchingDefault migration - Please be patient, this may take some time. This is not an error"); + + var settings = await unitOfWork.SettingsRepository.GetMetadataSettingDto(); + + + var shouldBeEnabled = settings != null && (settings.Enabled || settings.AgeRatingMappings.Count != 0 || + settings.Blacklist.Count != 0 || settings.Whitelist.Count != 0 || + settings.Whitelist.Count != 0 || settings.Blacklist.Count != 0 || + settings.FieldMappings.Count != 0); + + if (shouldBeEnabled && !settings.EnableExtendedMetadataProcessing) + { + var mSettings = await unitOfWork.SettingsRepository.GetMetadataSettings(); + mSettings.EnableExtendedMetadataProcessing = shouldBeEnabled; + await unitOfWork.CommitAsync(); + } + + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateEnableMetadataMatchingDefault", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateEnableMetadataMatchingDefault migration - Completed. This is not an error"); + } +} diff --git a/API/Data/Migrations/20250727185204_AddEnableExtendedMetadataProcessing.Designer.cs b/API/Data/Migrations/20250727185204_AddEnableExtendedMetadataProcessing.Designer.cs new file mode 100644 index 000000000..fe8bfb231 --- /dev/null +++ b/API/Data/Migrations/20250727185204_AddEnableExtendedMetadataProcessing.Designer.cs @@ -0,0 +1,3727 @@ +// +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("20250727185204_AddEnableExtendedMetadataProcessing")] + partial class AddEnableExtendedMetadataProcessing + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.7"); + + 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("EnableExtendedMetadataProcessing") + .HasColumnType("INTEGER"); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.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/20250727185204_AddEnableExtendedMetadataProcessing.cs b/API/Data/Migrations/20250727185204_AddEnableExtendedMetadataProcessing.cs new file mode 100644 index 000000000..6a35bcbdd --- /dev/null +++ b/API/Data/Migrations/20250727185204_AddEnableExtendedMetadataProcessing.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class AddEnableExtendedMetadataProcessing : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EnableExtendedMetadataProcessing", + table: "MetadataSettings", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EnableExtendedMetadataProcessing", + table: "MetadataSettings"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 62d1fb1ef..356c67819 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.7"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -1862,6 +1862,9 @@ namespace API.Data.Migrations .HasColumnType("INTEGER") .HasDefaultValue(true); + b.Property("EnableExtendedMetadataProcessing") + .HasColumnType("INTEGER"); + b.Property("EnableGenres") .HasColumnType("INTEGER"); diff --git a/API/Entities/MetadataMatching/MetadataSettings.cs b/API/Entities/MetadataMatching/MetadataSettings.cs index aeb44b619..b72329342 100644 --- a/API/Entities/MetadataMatching/MetadataSettings.cs +++ b/API/Entities/MetadataMatching/MetadataSettings.cs @@ -14,6 +14,11 @@ public class MetadataSettings /// public bool Enabled { get; set; } + /// + /// Enable processing of metadata outside K+; e.g. disk and API + /// + public bool EnableExtendedMetadataProcessing { get; set; } + #region Series Metadata /// diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index 0777e1baa..d8c197532 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -681,13 +681,34 @@ public class ExternalMetadataService : IExternalMetadataService return [.. staff]; } + /// + /// Helper method, calls + /// + /// + /// + /// + /// private static void GenerateGenreAndTagLists(ExternalSeriesDetailDto externalMetadata, MetadataSettingsDto settings, ref List processedTags, ref List processedGenres) { externalMetadata.Tags ??= []; externalMetadata.Genres ??= []; + GenerateGenreAndTagLists(externalMetadata.Genres, externalMetadata.Tags.Select(t => t.Name).ToList(), + settings, ref processedTags, ref processedGenres); + } - var mappings = ApplyFieldMappings(externalMetadata.Tags.Select(t => t.Name), MetadataFieldType.Tag, settings.FieldMappings); + /// + /// Run all genres and tags through the Metadata settings + /// + /// Genres to process + /// Tags to process + /// + /// + /// + private static void GenerateGenreAndTagLists(IList genres, IList tags, MetadataSettingsDto settings, + ref List processedTags, ref List processedGenres) + { + var mappings = ApplyFieldMappings(tags, MetadataFieldType.Tag, settings.FieldMappings); if (mappings.TryGetValue(MetadataFieldType.Tag, out var tagsToTags)) { processedTags.AddRange(tagsToTags); @@ -697,7 +718,7 @@ public class ExternalMetadataService : IExternalMetadataService processedGenres.AddRange(tagsToGenres); } - mappings = ApplyFieldMappings(externalMetadata.Genres, MetadataFieldType.Genre, settings.FieldMappings); + mappings = ApplyFieldMappings(genres, MetadataFieldType.Genre, settings.FieldMappings); if (mappings.TryGetValue(MetadataFieldType.Tag, out var genresToTags)) { processedTags.AddRange(genresToTags); @@ -711,6 +732,30 @@ public class ExternalMetadataService : IExternalMetadataService processedGenres = ApplyBlackWhiteList(settings, MetadataFieldType.Genre, processedGenres); } + /// + /// Processes the given tags and genres only if + /// is true, else return without change + /// + /// + /// + /// + /// + /// + public static void GenerateExternalGenreAndTagsList(IList genres, IList tags, + MetadataSettingsDto settings, out List processedTags, out List processedGenres) + { + if (!settings.EnableExtendedMetadataProcessing) + { + processedTags = [..tags]; + processedGenres = [..genres]; + return; + } + + processedTags = []; + processedGenres = []; + GenerateGenreAndTagLists(genres, tags, settings, ref processedTags, ref processedGenres); + } + private async Task UpdateRelationships(Series series, MetadataSettingsDto settings, IList? externalMetadataRelations, AppUser defaultAdmin) { if (!settings.EnableRelationships) return false; @@ -1003,16 +1048,19 @@ public class ExternalMetadataService : IExternalMetadataService private static List ApplyBlackWhiteList(MetadataSettingsDto settings, MetadataFieldType fieldType, List processedStrings) { + var whiteList = settings.Whitelist.Select(t => t.ToNormalized()).ToList(); + var blackList = settings.Blacklist.Select(t => t.ToNormalized()).ToList(); + return fieldType switch { MetadataFieldType.Genre => processedStrings.Distinct() - .Where(g => settings.Blacklist.Count == 0 || !settings.Blacklist.Contains(g)) + .Where(g => blackList.Count == 0 || !blackList.Contains(g.ToNormalized())) .ToList(), MetadataFieldType.Tag => processedStrings.Distinct() - .Where(g => settings.Blacklist.Count == 0 || !settings.Blacklist.Contains(g)) - .Where(g => settings.Whitelist.Count == 0 || settings.Whitelist.Contains(g)) + .Where(g => blackList.Count == 0 || !blackList.Contains(g.ToNormalized())) + .Where(g => whiteList.Count == 0 || whiteList.Contains(g.ToNormalized())) .ToList(), - _ => throw new ArgumentOutOfRangeException(nameof(fieldType), fieldType, null) + _ => throw new ArgumentOutOfRangeException(nameof(fieldType), fieldType, null), }; } @@ -1718,24 +1766,22 @@ public class ExternalMetadataService : IExternalMetadataService foreach (var value in values) { - var mapping = mappings.FirstOrDefault(m => + var matchingMappings = mappings.Where(m => m.SourceType == sourceType && - m.SourceValue.Equals(value, StringComparison.OrdinalIgnoreCase)); + m.SourceValue.ToNormalized().Equals(value.ToNormalized())); - if (mapping != null && !string.IsNullOrWhiteSpace(mapping.DestinationValue)) + var keepOriginal = true; + + foreach (var mapping in matchingMappings.Where(mapping => !string.IsNullOrWhiteSpace(mapping.DestinationValue))) { - var targetType = mapping.DestinationType; + result[mapping.DestinationType].Add(mapping.DestinationValue); - if (!mapping.ExcludeFromSource) - { - result[sourceType].Add(mapping.SourceValue); - } - - result[targetType].Add(mapping.DestinationValue); + // Only keep the original tags if none of the matches want to remove it + keepOriginal = keepOriginal && !mapping.ExcludeFromSource; } - else + + if (keepOriginal) { - // If no mapping, keep the original value result[sourceType].Add(value); } } @@ -1760,9 +1806,10 @@ public class ExternalMetadataService : IExternalMetadataService { // Find highest age rating from mappings mappings ??= new Dictionary(); + mappings = mappings.ToDictionary(k => k.Key.ToNormalized(), k => k.Value); return values - .Select(v => mappings.TryGetValue(v, out var mapping) ? mapping : AgeRating.Unknown) + .Select(v => mappings.TryGetValue(v.ToNormalized(), out var mapping) ? mapping : AgeRating.Unknown) .DefaultIfEmpty(AgeRating.Unknown) .Max(); } diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 78e3c41f1..bbcce5d3b 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -209,12 +209,17 @@ public class SeriesService : ISeriesService { var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); var allTags = series.Metadata.Tags.Select(t => t.Title).Concat(series.Metadata.Genres.Select(g => g.Title)); - var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, metadataSettings.AgeRatingMappings); - if (updatedRating > series.Metadata.AgeRating) + + if (metadataSettings.EnableExtendedMetadataProcessing) { - series.Metadata.AgeRating = updatedRating; - series.Metadata.KPlusOverrides.Remove(MetadataSettingField.AgeRating); + var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, metadataSettings.AgeRatingMappings); + if (updatedRating > series.Metadata.AgeRating) + { + series.Metadata.AgeRating = updatedRating; + series.Metadata.KPlusOverrides.Remove(MetadataSettingField.AgeRating); + } } + } } diff --git a/API/Services/SettingsService.cs b/API/Services/SettingsService.cs index fd44b5962..227d777ed 100644 --- a/API/Services/SettingsService.cs +++ b/API/Services/SettingsService.cs @@ -1,12 +1,15 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; using API.Data; +using API.DTOs; using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; +using API.Entities.MetadataMatching; using API.Extensions; using API.Logging; using API.Services.Tasks.Scanner; @@ -16,12 +19,21 @@ using Kavita.Common.EnvironmentInfo; using Kavita.Common.Helpers; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using SharpCompress.Common; namespace API.Services; public interface ISettingsService { Task UpdateMetadataSettings(MetadataSettingsDto dto); + /// + /// Update , , , + /// with data from the given dto. + /// + /// + /// + /// + Task ImportFieldMappings(FieldMappingsDto dto, ImportSettingsDto settings); Task UpdateSettings(ServerSettingDto updateSettingsDto); } @@ -54,6 +66,7 @@ public class SettingsService : ISettingsService { var existingMetadataSetting = await _unitOfWork.SettingsRepository.GetMetadataSettings(); existingMetadataSetting.Enabled = dto.Enabled; + existingMetadataSetting.EnableExtendedMetadataProcessing = dto.EnableExtendedMetadataProcessing; existingMetadataSetting.EnableSummary = dto.EnableSummary; existingMetadataSetting.EnableLocalizedName = dto.EnableLocalizedName; existingMetadataSetting.EnablePublicationStatus = dto.EnablePublicationStatus; @@ -108,6 +121,150 @@ public class SettingsService : ISettingsService return await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); } + public async Task ImportFieldMappings(FieldMappingsDto dto, ImportSettingsDto settings) + { + if (dto.AgeRatingMappings.Keys.Distinct().Count() != dto.AgeRatingMappings.Count) + { + throw new KavitaException("errors.import-fields.non-unique-age-ratings"); + } + + if (dto.FieldMappings.DistinctBy(f => f.Id).Count() != dto.FieldMappings.Count) + { + throw new KavitaException("errors.import-fields.non-unique-fields"); + } + + return settings.ImportMode switch + { + ImportMode.Merge => await MergeFieldMappings(dto, settings), + ImportMode.Replace => await ReplaceFieldMappings(dto, settings), + _ => throw new ArgumentOutOfRangeException(nameof(settings), $"Invalid import mode {nameof(settings.ImportMode)}") + }; + } + + /// + /// Will fully replace any enabled fields, always successful + /// + /// + /// + /// + private async Task ReplaceFieldMappings(FieldMappingsDto dto, ImportSettingsDto settings) + { + var existingMetadataSetting = await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); + + if (settings.Whitelist) + { + existingMetadataSetting.Whitelist = dto.Whitelist; + } + + if (settings.Blacklist) + { + existingMetadataSetting.Blacklist = dto.Blacklist; + } + + if (settings.AgeRatings) + { + existingMetadataSetting.AgeRatingMappings = dto.AgeRatingMappings; + } + + if (settings.FieldMappings) + { + existingMetadataSetting.FieldMappings = dto.FieldMappings; + } + + return new FieldMappingsImportResultDto + { + Success = true, + ResultingMetadataSettings = existingMetadataSetting, + AgeRatingConflicts = [], + }; + } + + /// + /// Tries to merge all enabled fields, fails if any merge was marked as manual. Always goes through all items + /// + /// + /// + /// + private async Task MergeFieldMappings(FieldMappingsDto dto, ImportSettingsDto settings) + { + var existingMetadataSetting = await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); + + if (settings.Whitelist) + { + existingMetadataSetting.Whitelist = existingMetadataSetting.Whitelist.Union(dto.Whitelist).DistinctBy(d => d.ToNormalized()).ToList(); + } + + if (settings.Blacklist) + { + existingMetadataSetting.Blacklist = existingMetadataSetting.Blacklist.Union(dto.Blacklist).DistinctBy(d => d.ToNormalized()).ToList(); + } + + List ageRatingConflicts = []; + + if (settings.AgeRatings) + { + foreach (var arm in dto.AgeRatingMappings) + { + if (!existingMetadataSetting.AgeRatingMappings.TryGetValue(arm.Key, out var mapping)) + { + existingMetadataSetting.AgeRatingMappings.Add(arm.Key, arm.Value); + continue; + } + + if (arm.Value == mapping) + { + continue; + } + + var resolution = settings.AgeRatingConflictResolutions.GetValueOrDefault(arm.Key, settings.Resolution); + + switch (resolution) + { + case ConflictResolution.Keep: continue; + case ConflictResolution.Replace: + existingMetadataSetting.AgeRatingMappings[arm.Key] = arm.Value; + break; + case ConflictResolution.Manual: + ageRatingConflicts.Add(arm.Key); + break; + default: + throw new ArgumentOutOfRangeException(nameof(settings), $"Invalid conflict resolution {nameof(ConflictResolution)}."); + } + } + } + + + if (settings.FieldMappings) + { + existingMetadataSetting.FieldMappings = existingMetadataSetting.FieldMappings + .Union(dto.FieldMappings) + .DistinctBy(fm => new + { + fm.SourceType, + SourceValue = fm.SourceValue.ToNormalized(), + fm.DestinationType, + DestinationValue = fm.DestinationValue.ToNormalized(), + }) + .ToList(); + } + + if (ageRatingConflicts.Count > 0) + { + return new FieldMappingsImportResultDto + { + Success = false, + AgeRatingConflicts = ageRatingConflicts, + }; + } + + return new FieldMappingsImportResultDto + { + Success = true, + ResultingMetadataSettings = existingMetadataSetting, + AgeRatingConflicts = [], + }; + } + /// /// Update Server Settings /// diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index 307408adb..d16e7b6e4 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using API.Data; using API.Data.Metadata; using API.Data.Repositories; +using API.DTOs.KavitaPlus.Metadata; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; @@ -29,7 +30,7 @@ namespace API.Services.Tasks.Scanner; public interface IProcessSeries { - Task ProcessSeriesAsync(IList parsedInfos, Library library, int totalToProcess, bool forceUpdate = false); + Task ProcessSeriesAsync(MetadataSettingsDto settings, IList parsedInfos, Library library, int totalToProcess, bool forceUpdate = false); } /// @@ -70,7 +71,7 @@ public class ProcessSeries : IProcessSeries } - public async Task ProcessSeriesAsync(IList parsedInfos, Library library, int totalToProcess, bool forceUpdate = false) + public async Task ProcessSeriesAsync(MetadataSettingsDto settings, IList parsedInfos, Library library, int totalToProcess, bool forceUpdate = false) { if (!parsedInfos.Any()) return; @@ -116,7 +117,7 @@ public class ProcessSeries : IProcessSeries // parsedInfos[0] is not the first volume or chapter. We need to find it using a ComicInfo check (as it uses firstParsedInfo for series sort) var firstParsedInfo = parsedInfos.FirstOrDefault(p => p.ComicInfo != null, firstInfo); - await UpdateVolumes(series, parsedInfos, forceUpdate); + await UpdateVolumes(settings, series, parsedInfos, forceUpdate); series.Pages = series.Volumes.Sum(v => v.Pages); series.NormalizedName = series.Name.ToNormalized(); @@ -151,7 +152,7 @@ public class ProcessSeries : IProcessSeries series.NormalizedLocalizedName = series.LocalizedName.ToNormalized(); } - await UpdateSeriesMetadata(series, library); + await UpdateSeriesMetadata(settings, series, library); // Update series FolderPath here await UpdateSeriesFolderPath(parsedInfos, library, series); @@ -288,7 +289,7 @@ public class ProcessSeries : IProcessSeries } - private async Task UpdateSeriesMetadata(Series series, Library library) + private async Task UpdateSeriesMetadata(MetadataSettingsDto settings, Series series, Library library) { series.Metadata ??= new SeriesMetadataBuilder().Build(); var firstChapter = SeriesService.GetFirstChapterForMetadata(series); @@ -311,14 +312,16 @@ public class ProcessSeries : IProcessSeries { series.Metadata.AgeRating = chapters.Max(chapter => chapter.AgeRating); - // Get the MetadataSettings and apply Age Rating Mappings here - var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); - var allTags = series.Metadata.Tags.Select(t => t.Title).Concat(series.Metadata.Genres.Select(g => g.Title)); - var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, metadataSettings.AgeRatingMappings); - if (updatedRating > series.Metadata.AgeRating) + if (settings.EnableExtendedMetadataProcessing) { - series.Metadata.AgeRating = updatedRating; + var allTags = series.Metadata.Tags.Select(t => t.Title).Concat(series.Metadata.Genres.Select(g => g.Title)); + var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, settings.AgeRatingMappings); + if (updatedRating > series.Metadata.AgeRating) + { + series.Metadata.AgeRating = updatedRating; + } } + } DeterminePublicationStatus(series, chapters); @@ -340,16 +343,16 @@ public class ProcessSeries : IProcessSeries } #region PeopleAndTagsAndGenres - if (!series.Metadata.WriterLocked) + if (!series.Metadata.WriterLocked) + { + var personSw = Stopwatch.StartNew(); + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Writer)).ToList(); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Writer)) { - var personSw = Stopwatch.StartNew(); - var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Writer)).ToList(); - if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Writer)) - { - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Writer); - } - _logger.LogTrace("[TIME] Kavita took {Time} ms to process writer on Series: {File} for {Count} people", personSw.ElapsedMilliseconds, series.Name, chapterPeople.Count); + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Writer); } + _logger.LogTrace("[TIME] Kavita took {Time} ms to process writer on Series: {File} for {Count} people", personSw.ElapsedMilliseconds, series.Name, chapterPeople.Count); + } if (!series.Metadata.ColoristLocked) { @@ -676,7 +679,7 @@ public class ProcessSeries : IProcessSeries } } - private async Task UpdateVolumes(Series series, IList parsedInfos, bool forceUpdate = false) + private async Task UpdateVolumes(MetadataSettingsDto settings, Series series, IList parsedInfos, bool forceUpdate = false) { // Add new volumes and update chapters per volume var distinctVolumes = parsedInfos.DistinctVolumes(); @@ -709,7 +712,7 @@ public class ProcessSeries : IProcessSeries var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray(); - await UpdateChapters(series, volume, infos, forceUpdate); + await UpdateChapters(settings, series, volume, infos, forceUpdate); volume.Pages = volume.Chapters.Sum(c => c.Pages); } @@ -746,7 +749,7 @@ public class ProcessSeries : IProcessSeries series.Volumes = nonDeletedVolumes; } - private async Task UpdateChapters(Series series, Volume volume, IList parsedInfos, bool forceUpdate = false) + private async Task UpdateChapters(MetadataSettingsDto settings, Series series, Volume volume, IList parsedInfos, bool forceUpdate = false) { // Add new chapters foreach (var info in parsedInfos) @@ -799,7 +802,7 @@ public class ProcessSeries : IProcessSeries try { - await UpdateChapterFromComicInfo(chapter, info.ComicInfo, forceUpdate); + await UpdateChapterFromComicInfo(settings, chapter, info.ComicInfo, forceUpdate); } catch (Exception ex) { @@ -900,7 +903,7 @@ public class ProcessSeries : IProcessSeries } } - private async Task UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? comicInfo, bool forceUpdate = false) + private async Task UpdateChapterFromComicInfo(MetadataSettingsDto settings, Chapter chapter, ComicInfo? comicInfo, bool forceUpdate = false) { if (comicInfo == null) return; var firstFile = chapter.Files.MinBy(x => x.Chapter); @@ -1069,16 +1072,25 @@ public class ProcessSeries : IProcessSeries await UpdateChapterPeopleAsync(chapter, people, PersonRole.Location); } - if (!chapter.GenresLocked) + if (!chapter.GenresLocked || !chapter.TagsLocked) { var genres = TagHelper.GetTagValues(comicInfo.Genre); - await UpdateChapterGenres(chapter, genres); - } - - if (!chapter.TagsLocked) - { var tags = TagHelper.GetTagValues(comicInfo.Tags); - await UpdateChapterTags(chapter, tags); + + ExternalMetadataService.GenerateExternalGenreAndTagsList(genres, tags, settings, + out var finalTags, out var finalGenres); + + if (!chapter.GenresLocked) + { + await UpdateChapterGenres(chapter, finalGenres); + } + + if (!chapter.TagsLocked) + { + await UpdateChapterTags(chapter, finalTags); + } + + } _logger.LogTrace("[TIME] Kavita took {Time} ms to create/update Chapter: {File}", sw.ElapsedMilliseconds, chapter.Files.First().FileName); diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index cb5f4302f..b8afdbd90 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -13,6 +13,7 @@ using API.Entities.Enums; using API.Extensions; using API.Helpers; using API.Helpers.Builders; +using API.Services.Plus; using API.Services.Tasks.Metadata; using API.Services.Tasks.Scanner; using API.Services.Tasks.Scanner.Parser; @@ -316,7 +317,8 @@ public class ScannerService : IScannerService { // Process Series var seriesProcessStopWatch = Stopwatch.StartNew(); - await _processSeries.ProcessSeriesAsync(parsedSeries[pSeries], library, seriesLeftToProcess, bypassFolderOptimizationChecks); + var settings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); + await _processSeries.ProcessSeriesAsync(settings, parsedSeries[pSeries], library, seriesLeftToProcess, bypassFolderOptimizationChecks); _logger.LogTrace("[TIME] Kavita took {Time} ms to process {SeriesName}", seriesProcessStopWatch.ElapsedMilliseconds, parsedSeries[pSeries][0].Series); seriesLeftToProcess--; } @@ -614,6 +616,8 @@ public class ScannerService : IScannerService var toProcess = new Dictionary>(); var scanSw = Stopwatch.StartNew(); + var settings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); + foreach (var series in parsedSeries) { if (!series.Key.HasChanged) @@ -638,22 +642,26 @@ public class ScannerService : IScannerService var allGenres = toProcess .SelectMany(s => s.Value .SelectMany(p => p.ComicInfo?.Genre? - .Split(",", StringSplitOptions.RemoveEmptyEntries) // Split on comma and remove empty entries - .Select(g => g.Trim()) // Trim each genre - .Where(g => !string.IsNullOrWhiteSpace(g)) // Ensure no null/empty genres - ?? [])); // Handle null Genre or ComicInfo safely - - await CreateAllGenresAsync(allGenres.Distinct().ToList()); + .Split(",", StringSplitOptions.RemoveEmptyEntries) + .Select(g => g.Trim()) + .Where(g => !string.IsNullOrWhiteSpace(g)) + ?? [])) + .Distinct().ToList(); var allTags = toProcess .SelectMany(s => s.Value .SelectMany(p => p.ComicInfo?.Tags? - .Split(",", StringSplitOptions.RemoveEmptyEntries) // Split on comma and remove empty entries - .Select(g => g.Trim()) // Trim each genre - .Where(g => !string.IsNullOrWhiteSpace(g)) // Ensure no null/empty genres - ?? [])); // Handle null Tag or ComicInfo safely + .Split(",", StringSplitOptions.RemoveEmptyEntries) + .Select(g => g.Trim()) + .Where(g => !string.IsNullOrWhiteSpace(g)) + ?? [])) + .Distinct().ToList(); - await CreateAllTagsAsync(allTags.Distinct().ToList()); + ExternalMetadataService.GenerateExternalGenreAndTagsList(allGenres, allTags, settings, + out var processedTags, out var processedGenres); + + await CreateAllGenresAsync(processedGenres); + await CreateAllTagsAsync(processedTags); } var totalFiles = 0; @@ -664,7 +672,7 @@ public class ScannerService : IScannerService { totalFiles += pSeries.Value.Count; var seriesProcessStopWatch = Stopwatch.StartNew(); - await _processSeries.ProcessSeriesAsync(pSeries.Value, library, seriesLeftToProcess, forceUpdate); + await _processSeries.ProcessSeriesAsync(settings, pSeries.Value, library, seriesLeftToProcess, forceUpdate); _logger.LogTrace("[TIME] Kavita took {Time} ms to process {SeriesName}", seriesProcessStopWatch.ElapsedMilliseconds, pSeries.Value[0].Series); seriesLeftToProcess--; } diff --git a/API/Startup.cs b/API/Startup.cs index f57cb7d01..2df56c504 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -296,6 +296,9 @@ public class Startup // v0.8.7 await ManualMigrateReadingProfiles.Migrate(dataContext, logger); + // v0.8.8 + await ManualMigrateEnableMetadataMatchingDefault.Migrate(dataContext, unitOfWork, logger); + #endregion // Update the version in the DB after all migrations are run diff --git a/UI/Web/angular.json b/UI/Web/angular.json index 1ce56fa2e..dd9000525 100644 --- a/UI/Web/angular.json +++ b/UI/Web/angular.json @@ -13,7 +13,8 @@ "projectType": "application", "schematics": { "@schematics/angular:component": { - "style": "scss" + "style": "scss", + "changeDetection": "OnPush" }, "@schematics/angular:application": { "strict": true diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index cfce8cded..8d6b0ebd6 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -541,7 +541,6 @@ "version": "19.2.5", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.5.tgz", "integrity": "sha512-b2cG41r6lilApXLlvja1Ra2D00dM3BxmQhoElKC1tOnpD6S3/krlH1DOnBB2I55RBn9iv4zdmPz1l8zPUSh7DQ==", - "dev": true, "dependencies": { "@babel/core": "7.26.9", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -569,7 +568,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", - "dev": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -584,7 +582,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", - "dev": true, "engines": { "node": ">= 14.16.0" }, @@ -4906,8 +4903,7 @@ "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/cosmiconfig": { "version": "8.3.6", @@ -5354,7 +5350,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -5364,7 +5359,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -8181,8 +8175,7 @@ "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, "node_modules/replace-in-file": { "version": "7.1.0", @@ -8403,7 +8396,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "devOptional": true }, "node_modules/sass": { "version": "1.85.0", @@ -8468,7 +8461,6 @@ "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -9093,7 +9085,6 @@ "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", - "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/UI/Web/src/app/_models/import-field-mappings.ts b/UI/Web/src/app/_models/import-field-mappings.ts new file mode 100644 index 000000000..eaedbdb33 --- /dev/null +++ b/UI/Web/src/app/_models/import-field-mappings.ts @@ -0,0 +1,32 @@ +import {MetadataSettings} from "../admin/_models/metadata-settings"; + +export enum ImportMode { + Replace = 0, + Merge = 1, +} + +export const ImportModes = [ImportMode.Replace, ImportMode.Merge]; + +export enum ConflictResolution { + Manual = 0, + Keep = 1, + Replace = 2, +} + +export const ConflictResolutions = [ConflictResolution.Manual, ConflictResolution.Keep, ConflictResolution.Replace]; + +export interface ImportSettings { + importMode: ImportMode; + resolution: ConflictResolution; + whitelist: boolean; + blacklist: boolean; + ageRatings: boolean; + fieldMappings: boolean; + ageRatingConflictResolutions: Record; +} + +export interface FieldMappingsImportResult { + success: boolean; + resultingMetadataSettings: MetadataSettings; + ageRatingConflicts: string[]; +} diff --git a/UI/Web/src/app/_pipes/conflict-resolution.pipe.ts b/UI/Web/src/app/_pipes/conflict-resolution.pipe.ts new file mode 100644 index 000000000..961187cc0 --- /dev/null +++ b/UI/Web/src/app/_pipes/conflict-resolution.pipe.ts @@ -0,0 +1,26 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {translate} from "@jsverse/transloco"; +import {ConflictResolution} from "../_models/import-field-mappings"; + +@Pipe({ + name: 'conflictResolution' +}) +export class ConflictResolutionPipe implements PipeTransform { + + transform(value: ConflictResolution | null | string): string { + if (typeof value === 'string') { + value = parseInt(value, 10); + } + switch (value) { + case ConflictResolution.Manual: + return translate('import-mappings.manual'); + case ConflictResolution.Keep: + return translate('import-mappings.keep'); + case ConflictResolution.Replace: + return translate('import-mappings.replace'); + } + + return translate('common.unknown'); + } + +} diff --git a/UI/Web/src/app/_pipes/import-mode.pipe.ts b/UI/Web/src/app/_pipes/import-mode.pipe.ts new file mode 100644 index 000000000..41bcdca2b --- /dev/null +++ b/UI/Web/src/app/_pipes/import-mode.pipe.ts @@ -0,0 +1,25 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {translate} from "@jsverse/transloco"; +import {ImportMode} from "../_models/import-field-mappings"; + +@Pipe({ + name: 'importMode' +}) +export class ImportModePipe implements PipeTransform { + + transform(value: ImportMode | null | string): string { + if (typeof value === 'string') { + value = parseInt(value, 10); + } + + switch (value) { + case ImportMode.Replace: + return translate('import-mappings.replace'); + case ImportMode.Merge: + return translate('import-mappings.merge'); + } + + return translate('common.unknown'); + } + +} diff --git a/UI/Web/src/app/_pipes/metadata-field-type.pipe.ts b/UI/Web/src/app/_pipes/metadata-field-type.pipe.ts new file mode 100644 index 000000000..a81ea627f --- /dev/null +++ b/UI/Web/src/app/_pipes/metadata-field-type.pipe.ts @@ -0,0 +1,25 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {MetadataFieldType} from "../admin/_models/metadata-settings"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'metadataFieldType' +}) +export class MetadataFieldTypePipe implements PipeTransform { + + transform(value: MetadataFieldType | null | string): string { + if (typeof value === 'string') { + value = parseInt(value, 10); + } + + switch (value) { + case MetadataFieldType.Genre: + return translate('manage-metadata-settings.genre'); + case MetadataFieldType.Tag: + return translate('manage-metadata-settings.tag'); + default: + return translate('common.unknown'); + } + } + +} diff --git a/UI/Web/src/app/_services/license.service.ts b/UI/Web/src/app/_services/license.service.ts index a2e77f2fe..59ab91457 100644 --- a/UI/Web/src/app/_services/license.service.ts +++ b/UI/Web/src/app/_services/license.service.ts @@ -4,6 +4,7 @@ import {catchError, map, ReplaySubject, tap, throwError} from "rxjs"; import {environment} from "../../environments/environment"; import {TextResonse} from '../_types/text-response'; import {LicenseInfo} from "../_models/kavitaplus/license-info"; +import {toSignal} from "@angular/core/rxjs-interop"; @Injectable({ providedIn: 'root' @@ -18,6 +19,7 @@ export class LicenseService { * Does the user have an active license */ public readonly hasValidLicense$ = this.hasValidLicenseSource.asObservable(); + public readonly hasValidLicenseSignal = toSignal(this.hasValidLicense$, {initialValue: false}); /** diff --git a/UI/Web/src/app/admin/_models/metadata-settings.ts b/UI/Web/src/app/admin/_models/metadata-settings.ts index 9743dd578..671efd731 100644 --- a/UI/Web/src/app/admin/_models/metadata-settings.ts +++ b/UI/Web/src/app/admin/_models/metadata-settings.ts @@ -18,6 +18,7 @@ export interface MetadataFieldMapping { export interface MetadataSettings { enabled: boolean; + enableExtendedMetadataProcessing: boolean; enableSummary: boolean; enablePublicationStatus: boolean; enableRelationships: boolean; @@ -36,7 +37,7 @@ export interface MetadataSettings { enableGenres: boolean; enableTags: boolean; firstLastPeopleNaming: boolean; - ageRatingMappings: Map; + ageRatingMappings: Record; fieldMappings: Array; blacklist: Array; whitelist: Array; diff --git a/UI/Web/src/app/admin/import-mappings/import-mappings.component.html b/UI/Web/src/app/admin/import-mappings/import-mappings.component.html new file mode 100644 index 000000000..35a827c05 --- /dev/null +++ b/UI/Web/src/app/admin/import-mappings/import-mappings.component.html @@ -0,0 +1,171 @@ + + +
+ +
+ + + @if (!isLoading()) { +
+ @switch (currentStepIndex()) { + @case (Step.Import) { +
+

{{t('import-description')}}

+
+ +
+
+ } + @case (Step.Configure) { +
+
+ @if (importSettingsForm.get('importMode'); as control) { + + + + + + } +
+ +
+ @if (importSettingsForm.get('resolution'); as control) { + + + + + + } +
+ +
+ +
{{t('fields-to-import')}}
+
{{t('fields-to-import-tooltip')}}
+ +
+ @if (importSettingsForm.get('whitelist'); as control) { + + +
+ +
+
+
+ } +
+ +
+ @if (importSettingsForm.get('blacklist'); as control) { + + +
+ +
+
+
+ } +
+ +
+ @if (importSettingsForm.get('ageRatings'); as control) { + + +
+ +
+
+
+ } +
+ +
+ @if (importSettingsForm.get('fieldMappings'); as control) { + + +
+ +
+
+
+ } +
+
+ +
+ } + @case (Step.Conflicts) { + @let res = importResult(); + @if (res) { +
+ + @if (res.ageRatingConflicts.length > 0) { +
{{t('age-ratings-label')}}
+
{{t('age-ratings-conflicts-tooltip')}}
+ } + + @for (arm of res.ageRatingConflicts; track arm) { +
+ @if (importSettingsForm.get('ageRatingConflictResolutions.' + arm); as control) { +
+ {{arm}} + +
+ } +
+ } + +
+ } + } + @case (Step.Finalize) { + @let res = importResult(); + @if (res) { + + + } + } + } +
+ } + + + +
+ + + @let oldValue = settings()!.ageRatingMappings[arm]; + @let newValue = importedMappings()!.ageRatingMappings[arm]; + + @switch (resolution) { + @case (ConflictResolution.Manual) { {{'import-mappings.to-pick' | transloco}} } + @case (ConflictResolution.Keep) { {{ oldValue | ageRating }} } + @case (ConflictResolution.Replace) { {{ newValue | ageRating }} } + } + diff --git a/UI/Web/src/app/admin/import-mappings/import-mappings.component.scss b/UI/Web/src/app/admin/import-mappings/import-mappings.component.scss new file mode 100644 index 000000000..bbd8a26bb --- /dev/null +++ b/UI/Web/src/app/admin/import-mappings/import-mappings.component.scss @@ -0,0 +1,50 @@ +.file-input { + display: none; +} + +.heading-badge { + color: var(--bs-badge-color); +} + +::ng-deep .file-info { + width: 83%; + float: left; +} + +::ng-deep .file-buttons { + float: right; +} + +file-upload { + background: none; + height: auto; +} + +::ng-deep .upload-input { + color: var(--input-text-color) !important; +} + +::ng-deep file-upload-list-item { + color: var(--input-text-color) !important; +} + +.conflict-group-title { + font-size: 1.5rem; + font-weight: bold; + color: var(--accent-text-color); +} + +.conflict-title { + font-size: 1.2rem; + color: var(--primary-color); +} + +.reset-color { + color: var(--accent-text-color); +} + +.break { + height: 1px; + background-color: var(--setting-break-color); + margin: 10px 0; +} diff --git a/UI/Web/src/app/admin/import-mappings/import-mappings.component.ts b/UI/Web/src/app/admin/import-mappings/import-mappings.component.ts new file mode 100644 index 000000000..e5c2db57c --- /dev/null +++ b/UI/Web/src/app/admin/import-mappings/import-mappings.component.ts @@ -0,0 +1,307 @@ +import {Component, computed, inject, OnInit, signal, ViewChild} from '@angular/core'; +import {translate, TranslocoDirective, TranslocoPipe} from "@jsverse/transloco"; +import {StepTrackerComponent, TimelineStep} from "../../reading-list/_components/step-tracker/step-tracker.component"; +import {WikiLink} from "../../_models/wiki"; +import { + AbstractControl, + FormArray, + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, + ValidatorFn, + Validators +} from "@angular/forms"; +import {FileUploadComponent, FileUploadValidators} from "@iplab/ngx-file-upload"; +import {MetadataSettings} from "../_models/metadata-settings"; +import {SettingsService} from "../settings.service"; +import { + ManageMetadataMappingsComponent, + MetadataMappingsExport +} from "../manage-metadata-mappings/manage-metadata-mappings.component"; +import {ToastrService} from "ngx-toastr"; +import {LoadingComponent} from "../../shared/loading/loading.component"; +import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; +import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; +import {ImportModePipe} from "../../_pipes/import-mode.pipe"; +import {ConflictResolutionPipe} from "../../_pipes/conflict-resolution.pipe"; +import { + ConflictResolution, + ConflictResolutions, + FieldMappingsImportResult, + ImportMode, + ImportModes, + ImportSettings +} from "../../_models/import-field-mappings"; +import {firstValueFrom, switchMap} from "rxjs"; +import {tap} from "rxjs/operators"; +import {AgeRatingPipe} from "../../_pipes/age-rating.pipe"; +import {NgTemplateOutlet} from "@angular/common"; +import {Router} from "@angular/router"; +import {LicenseService} from "../../_services/license.service"; +import {SettingsTabId} from "../../sidenav/preference-nav/preference-nav.component"; + +enum Step { + Import = 0, + Configure = 1, + Conflicts = 2, + Finalize = 3, +} + +@Component({ + selector: 'app-import-mappings', + imports: [ + TranslocoDirective, + StepTrackerComponent, + FileUploadComponent, + FormsModule, + ReactiveFormsModule, + LoadingComponent, + SettingSwitchComponent, + SettingItemComponent, + ImportModePipe, + ConflictResolutionPipe, + AgeRatingPipe, + NgTemplateOutlet, + TranslocoPipe, + ManageMetadataMappingsComponent, + ], + templateUrl: './import-mappings.component.html', + styleUrl: './import-mappings.component.scss' +}) +export class ImportMappingsComponent implements OnInit { + + private readonly router = inject(Router); + private readonly licenseService = inject(LicenseService); + private readonly settingsService = inject(SettingsService); + private readonly toastr = inject(ToastrService); + + @ViewChild(ManageMetadataMappingsComponent) manageMetadataMappingsComponent!: ManageMetadataMappingsComponent; + + steps: TimelineStep[] = [ + {title: translate('import-mappings.import-step'), index: Step.Import, active: true, icon: 'fa-solid fa-file-arrow-up'}, + {title: translate('import-mappings.configure-step'), index: Step.Configure, active: false, icon: 'fa-solid fa-gears'}, + {title: translate('import-mappings.conflicts-step'), index: Step.Conflicts, active: false, icon: 'fa-solid fa-hammer'}, + {title: translate('import-mappings.finalize-step'), index: Step.Finalize, active: false, icon: 'fa-solid fa-floppy-disk'}, + ]; + currentStepIndex = signal(this.steps[0].index); + + fileUploadControl = new FormControl>(undefined, [ + FileUploadValidators.accept(['.json']), FileUploadValidators.filesLimit(1) + ]); + + uploadForm = new FormGroup({ + files: this.fileUploadControl, + }); + importSettingsForm = new FormGroup({ + importMode: new FormControl(ImportMode.Merge, [Validators.required]), + resolution: new FormControl(ConflictResolution.Manual), + whitelist: new FormControl(true), + blacklist: new FormControl(true), + ageRatings: new FormControl(true), + fieldMappings: new FormControl(true), + ageRatingConflictResolutions: new FormGroup({}), + }); + /** + * This is that contains the data in the finalize step + */ + mappingsForm = new FormGroup({}); + + isLoading = signal(false); + settings = signal(undefined) + importedMappings = signal(undefined); + importResult = signal(undefined); + + nextButtonLabel = computed(() => { + switch(this.currentStepIndex()) { + case Step.Configure: + case Step.Conflicts: + return 'import'; + case Step.Finalize: + return 'save'; + default: + return 'next'; + } + }); + + canMoveToNextStep = computed(() => { + switch (this.currentStepIndex()) { + case Step.Import: + return this.isFileSelected(); + case Step.Finalize: + case Step.Configure: + return true; + case Step.Conflicts: + return this.importSettingsForm.valid; + default: + return false; + } + }); + + canMoveToPrevStep = computed(() => { + switch (this.currentStepIndex()) { + case Step.Import: + return false; + default: + return true; + } + }); + + ngOnInit(): void { + this.settingsService.getMetadataSettings().subscribe((settings) => { + this.settings.set(settings); + }); + } + + async nextStep() { + if (this.currentStepIndex() === Step.Import && !this.isFileSelected()) return; + + this.isLoading.set(true); + try { + switch(this.currentStepIndex()) { + case Step.Import: + await this.validateImport(); + break; + case Step.Conflicts: + case Step.Configure: + await this.tryImport(); + break; + case Step.Finalize: + this.save(); + } + } catch (error) { + /** Swallow **/ + } + + this.isLoading.set(false); + } + + save() { + const res = this.importResult(); + if (!res) return; + + const newSettings = res.resultingMetadataSettings; + const data = this.manageMetadataMappingsComponent.packData(); + + // Update settings with data from the final step + newSettings.whitelist = data.whitelist; + newSettings.blacklist = data.blacklist; + newSettings.ageRatingMappings = data.ageRatingMappings; + newSettings.fieldMappings = data.fieldMappings; + + this.settingsService.updateMetadataSettings(newSettings).subscribe({ + next: () => { + const fragment = this.licenseService.hasValidLicenseSignal() + ? SettingsTabId.Metadata : SettingsTabId.ManageMetadata; + + this.router.navigate(['settings'], { fragment: fragment }); + } + }); + } + + async tryImport() { + const data = this.importedMappings(); + if (!data) { + this.toastr.error(translate('import-mappings.file-no-valid-content')); + return Promise.resolve(); + } + + const settings = this.importSettingsForm.value as ImportSettings; + + return firstValueFrom(this.settingsService.importFieldMappings(data, settings).pipe( + tap((res) => this.importResult.set(res)), + switchMap((res) => { + return this.settingsService.getMetadataSettings().pipe( + tap(dto => this.settings.set(dto)), + tap(() => { + if (res.success) { + this.currentStepIndex.set(Step.Finalize); + return; + } + + this.setupSettingConflicts(res); + this.currentStepIndex.set(Step.Conflicts); + }), + )}), + )); + } + + async validateImport() { + const files = this.fileUploadControl.value; + if (!files || files.length === 0) { + this.toastr.error(translate('import-mappings.select-files-warning')); + return; + } + + const file = files[0]; + let newImport: MetadataMappingsExport; + try { + newImport = JSON.parse(await file.text()) as MetadataMappingsExport; + } catch (error) { + this.toastr.error(translate('import-mappings.invalid-file')); + return; + } + if (!newImport.fieldMappings && !newImport.ageRatingMappings && !newImport.blacklist && !newImport.whitelist) { + this.toastr.error(translate('import-mappings.file-no-valid-content')); + return; + } + + this.importedMappings.set(newImport); + this.currentStepIndex.update(x=>x + 1); + } + + private setupSettingConflicts(res: FieldMappingsImportResult) { + const ageRatingGroup = this.importSettingsForm.get('ageRatingConflictResolutions')! as FormGroup; + + for (let key of res.ageRatingConflicts) { + if (!ageRatingGroup.get(key)) { + ageRatingGroup.addControl(key, new FormControl(ConflictResolution.Manual, [this.notManualValidator()])) + } + } + } + + private notManualValidator(): ValidatorFn { + return (control: AbstractControl) => { + const value = control.value; + try { + if (parseInt(value, 10) !== ConflictResolution.Manual) return null; + } catch (e) { + } + + return {'notManualValidator': {'value': value}} + } + } + + prevStep() { + if (this.currentStepIndex() === Step.Import) return; + + if (this.currentStepIndex() === Step.Finalize) { + if (this.importResult()!.ageRatingConflicts.length === 0) { + this.currentStepIndex.set(Step.Configure); + } else { + this.currentStepIndex.set(Step.Conflicts); + } + return; + } + + this.currentStepIndex.update(x => x - 1); + + // Reset when returning to the first step + if (this.currentStepIndex() === Step.Import) { + this.fileUploadControl.reset(); + (this.importSettingsForm.get('ageRatingConflictResolutions') as FormArray).clear(); + } + + } + + isFileSelected() { + const files = this.uploadForm.get('files')?.value; + return files && files.length === 1; + } + + protected readonly Step = Step; + protected readonly WikiLink = WikiLink; + protected readonly ImportModes = ImportModes; + protected readonly ConflictResolutions = ConflictResolutions; + protected readonly ConflictResolution = ConflictResolution; +} diff --git a/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.html b/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.html new file mode 100644 index 000000000..e52b33434 --- /dev/null +++ b/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.html @@ -0,0 +1,151 @@ + + +
+
+ @if(settingsForm().get('blacklist'); as formControl) { + + + + @let val = breakTags(formControl.value); + @for(opt of val; track opt) { + {{opt.trim()}} + } @empty { + {{null | defaultValue}} + } + + + + + + + + } +
+
+ + +
+ @if(settingsForm().get('whitelist'); as formControl) { + + + @let val = breakTags(formControl.value); + + @for(opt of val; track opt) { + {{opt.trim()}} + } @empty { + {{null | defaultValue}} + } + s + + + + + } +
+ +
+ +

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

+

{{t('age-rating-mapping-description')}}

+ +
+ @for(mapping of ageRatingMappings.controls; track mapping; let i = $index) { +
+
+ +
+
+ +
+
+ +
+
+ + + @if($last) { + + } +
+
+ } @empty { + + } + + +
+ +
+ +

{{t('field-mapping-title')}}

+

{{t('field-mapping-description')}}

+
+ @for (mapping of fieldMappings.controls; track mapping; let i = $index) { +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+
+ + + @if ($last) { + + } +
+
+ } @empty { + + } + +
+
diff --git a/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.scss b/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.scss new file mode 100644 index 000000000..209e84b5c --- /dev/null +++ b/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.scss @@ -0,0 +1,3 @@ +.text-muted { + font-size: 0.875rem; +} diff --git a/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.ts b/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.ts new file mode 100644 index 000000000..24531353d --- /dev/null +++ b/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.ts @@ -0,0 +1,165 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, input, OnInit, signal} from '@angular/core'; +import {AgeRatingPipe} from "../../_pipes/age-rating.pipe"; +import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; +import {FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms"; +import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; +import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component"; +import {MetadataFieldMapping, MetadataFieldType, MetadataSettings} from "../_models/metadata-settings"; +import {AgeRatingDto} from "../../_models/metadata/age-rating-dto"; +import {MetadataService} from "../../_services/metadata.service"; +import {translate, TranslocoDirective} from "@jsverse/transloco"; +import {AgeRating} from "../../_models/metadata/age-rating"; +import {DownloadService} from "../../shared/_services/download.service"; + +export type MetadataMappingsExport = { + ageRatingMappings: Record, + fieldMappings: Array, + blacklist: Array, + whitelist: Array, +} + +@Component({ + selector: 'app-manage-metadata-mappings', + imports: [ + AgeRatingPipe, + DefaultValuePipe, + FormsModule, + ReactiveFormsModule, + SettingItemComponent, + TagBadgeComponent, + TranslocoDirective, + ], + templateUrl: './manage-metadata-mappings.component.html', + styleUrl: './manage-metadata-mappings.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ManageMetadataMappingsComponent implements OnInit { + + private readonly downloadService = inject(DownloadService); + private readonly metadataService = inject(MetadataService); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly fb = inject(FormBuilder); + + + /** + * The FormGroup to use, this component will add its own controls + */ + settingsForm = input.required(); + settings = input.required() + /** + * If we should display the extended metadata processing toggle and export button + */ + showHeader = input(true); + + ageRatings = signal>([]); + + ageRatingMappings = this.fb.array, + rating: FormControl + }>>([]); + fieldMappings = this.fb.array + sourceType: FormControl, + destinationType: FormControl, + sourceValue: FormControl, + destinationValue: FormControl, + excludeFromSource: FormControl, + }>>([]); + + ngOnInit(): void { + this.metadataService.getAllAgeRatings().subscribe(ratings => { + this.ageRatings.set(ratings); + }); + + const settings = this.settings(); + const settingsForm = this.settingsForm(); + + settingsForm.addControl('blacklist', new FormControl((settings.blacklist || '').join(','), [])); + settingsForm.addControl('whitelist', new FormControl((settings.whitelist || '').join(','), [])); + settingsForm.addControl('ageRatingMappings', this.ageRatingMappings); + settingsForm.addControl('fieldMappings', this.fieldMappings); + + if (settings.ageRatingMappings) { + Object.entries(settings.ageRatingMappings).forEach(([str, rating]) => { + this.addAgeRatingMapping(str, rating); + }); + } + + if (settings.fieldMappings) { + settings.fieldMappings.forEach(mapping => { + this.addFieldMapping(mapping); + }); + } + + this.cdRef.markForCheck(); + } + + breakTags(csString: string) { + if (csString) { + return csString.split(','); + } + + return []; + } + + public packData(): MetadataMappingsExport { + const ageRatingMappings = this.ageRatingMappings.controls.reduce((acc: Record, control) => { + const { str, rating } = control.value; + if (str && rating) { + acc[str] = rating; + } + return acc; + }, {}); + + const fieldMappings = this.fieldMappings.controls + .map((control) => control.value as MetadataFieldMapping) + .filter(m => m.sourceValue.length > 0 && m.destinationValue.length > 0); + + const blacklist = (this.settingsForm().get('blacklist')?.value || '').split(',').map((item: string) => item.trim()).filter((tag: string) => tag.length > 0); + const whitelist = (this.settingsForm().get('whitelist')?.value || '').split(',').map((item: string) => item.trim()).filter((tag: string) => tag.length > 0); + + return { + ageRatingMappings: ageRatingMappings, + fieldMappings: fieldMappings, + blacklist: blacklist, + whitelist: whitelist, + } + } + + export() { + const data = this.packData(); + this.downloadService.downloadObjectAsJson(data, translate('manage-metadata-settings.export-file-name')) + } + + addAgeRatingMapping(str: string = '', rating: AgeRating = AgeRating.Unknown) { + const mappingGroup = this.fb.group({ + str: [str, Validators.required], + rating: [rating, Validators.required] + }); + + this.ageRatingMappings.push(mappingGroup); + } + + removeAgeRatingMappingRow(index: number) { + this.ageRatingMappings.removeAt(index); + } + + addFieldMapping(mapping: MetadataFieldMapping | null = null) { + const mappingGroup = this.fb.group({ + id: [mapping?.id || 0], + sourceType: [mapping?.sourceType || MetadataFieldType.Genre, Validators.required], + destinationType: [mapping?.destinationType || MetadataFieldType.Genre, Validators.required], + sourceValue: [mapping?.sourceValue || '', Validators.required], + destinationValue: [mapping?.destinationValue || ''], + excludeFromSource: [mapping?.excludeFromSource || false] + }); + + this.fieldMappings.push(mappingGroup); + } + + removeFieldMappingRow(index: number) { + this.fieldMappings.removeAt(index); + } + + protected readonly MetadataFieldType = MetadataFieldType; +} diff --git a/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.html b/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.html index 658dfd054..bdd83f8f0 100644 --- a/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.html +++ b/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.html @@ -1,22 +1,54 @@ -

{{t('description')}}

@if (isLoaded) {
- @if(settingsForm.get('enabled'); as formControl) { - - -
- -
-
-
- } +
+ @if(settingsForm.get('enabled'); as formControl) { + + +
+ +
+
+
+ } +
+ +
+ @if (settingsForm.get('enableExtendedMetadataProcessing'); as control) { + + +
+ +
+
+
+ } +
+
+
+ +
{{t('export-tooltip')}}
+
+ +
+ +
{{t('import-tooltip')}}
+
+
+ +
+ +

{{t('series-header')}}

@if(settingsForm.get('enableSummary'); as formControl) { @@ -91,8 +123,7 @@
- -
{{t('chapter-header')}}
+

{{t('chapter-header')}}

@if(settingsForm.get('enableChapterTitle'); as formControl) { @@ -155,6 +186,7 @@ @if(settingsForm.get('enablePeople'); as formControl) {
+

{{t('people-header')}}

@@ -195,13 +227,10 @@ } } - - -
- -
+

{{t('tags-header')}}

+
@if(settingsForm.get('enableGenres'); as formControl) { @@ -226,144 +255,9 @@
-
- @if(settingsForm.get('blacklist'); as formControl) { - - - @let val = breakTags(formControl.value); - - @for(opt of val; track opt) { - {{opt.trim()}} - } @empty { - {{null | defaultValue}} - } - s - - - - - } -
- -
- @if(settingsForm.get('whitelist'); as formControl) { - - - @let val = breakTags(formControl.value); - - @for(opt of val; track opt) { - {{opt.trim()}} - } @empty { - {{null | defaultValue}} - } - s - - - - - } -
- -
- -

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

-

{{t('age-rating-mapping-description')}}

- -
- @for(mapping of ageRatingMappings.controls; track mapping; let i = $index) { -
-
- -
-
- -
-
- -
-
- - - @if($last) { - - } -
-
- } @empty { - - } - - -
- -
- - -

{{t('field-mapping-title')}}

-

{{t('field-mapping-description')}}

-
- @for (mapping of fieldMappings.controls; track mapping; let i = $index) { -
-
- -
-
- -
-
- -
-
- -
-
-
- - -
-
-
- - - @if ($last) { - - } -
-
- } @empty { - - } - -
+ @if (settings) { + + }
diff --git a/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.ts b/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.ts index d84478e92..7c870096b 100644 --- a/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.ts +++ b/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.ts @@ -1,23 +1,31 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + inject, + OnInit, + ViewChild +} from '@angular/core'; import {TranslocoDirective} from "@jsverse/transloco"; import {FormArray, FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; -import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; -import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; -import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component"; import {SettingsService} from "../settings.service"; import {debounceTime, switchMap} from "rxjs"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import {filter, map} from "rxjs/operators"; -import {AgeRatingPipe} from "../../_pipes/age-rating.pipe"; -import {AgeRating} from "../../_models/metadata/age-rating"; -import {MetadataService} from "../../_services/metadata.service"; -import {AgeRatingDto} from "../../_models/metadata/age-rating-dto"; -import {MetadataFieldMapping, MetadataFieldType} from "../_models/metadata-settings"; +import {map} from "rxjs/operators"; +import {MetadataSettings} from "../_models/metadata-settings"; import {PersonRole} from "../../_models/metadata/person"; import {PersonRolePipe} from "../../_pipes/person-role.pipe"; import {allMetadataSettingField, MetadataSettingField} from "../_models/metadata-setting-field"; import {MetadataSettingFiledPipe} from "../../_pipes/metadata-setting-filed.pipe"; +import { + ManageMetadataMappingsComponent, + MetadataMappingsExport +} from "../manage-metadata-mappings/manage-metadata-mappings.component"; +import {AgeRating} from "../../_models/metadata/age-rating"; +import {RouterLink} from "@angular/router"; +import {SettingsTabId} from "../../sidenav/preference-nav/preference-nav.component"; @Component({ @@ -26,12 +34,10 @@ import {MetadataSettingFiledPipe} from "../../_pipes/metadata-setting-filed.pipe TranslocoDirective, ReactiveFormsModule, SettingSwitchComponent, - SettingItemComponent, - DefaultValuePipe, - TagBadgeComponent, - AgeRatingPipe, PersonRolePipe, MetadataSettingFiledPipe, + ManageMetadataMappingsComponent, + RouterLink, ], templateUrl: './manage-metadata-settings.component.html', @@ -40,34 +46,26 @@ import {MetadataSettingFiledPipe} from "../../_pipes/metadata-setting-filed.pipe }) export class ManageMetadataSettingsComponent implements OnInit { - protected readonly MetadataFieldType = MetadataFieldType; + @ViewChild(ManageMetadataMappingsComponent) manageMetadataMappingsComponent!: ManageMetadataMappingsComponent; private readonly settingService = inject(SettingsService); - private readonly metadataService = inject(MetadataService); private readonly cdRef = inject(ChangeDetectorRef); private readonly destroyRef = inject(DestroyRef); private readonly fb = inject(FormBuilder); settingsForm: FormGroup = new FormGroup({}); - ageRatings: Array = []; - ageRatingMappings = this.fb.array([]); - fieldMappings = this.fb.array([]); + settings: MetadataSettings | undefined = undefined; personRoles: PersonRole[] = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character]; isLoaded = false; allMetadataSettingFields = allMetadataSettingField; ngOnInit(): void { - this.metadataService.getAllAgeRatings().subscribe(ratings => { - this.ageRatings = ratings; - this.cdRef.markForCheck(); - }); - - - this.settingsForm.addControl('ageRatingMappings', this.ageRatingMappings); - this.settingsForm.addControl('fieldMappings', this.fieldMappings); - this.settingService.getMetadataSettings().subscribe(settings => { + this.settings = settings; + this.cdRef.markForCheck(); + this.settingsForm.addControl('enabled', new FormControl(settings.enabled, [])); + this.settingsForm.addControl('enableExtendedMetadataProcessing', new FormControl(settings.enableExtendedMetadataProcessing, [])); this.settingsForm.addControl('enableSummary', new FormControl(settings.enableSummary, [])); this.settingsForm.addControl('enableLocalizedName', new FormControl(settings.enableLocalizedName, [])); this.settingsForm.addControl('enablePublicationStatus', new FormControl(settings.enablePublicationStatus, [])); @@ -86,8 +84,6 @@ export class ManageMetadataSettingsComponent implements OnInit { this.settingsForm.addControl('enableChapterPublisher', new FormControl(settings.enableChapterPublisher, [])); this.settingsForm.addControl('enableChapterCoverImage', new FormControl(settings.enableChapterCoverImage, [])); - this.settingsForm.addControl('blacklist', new FormControl((settings.blacklist || '').join(','), [])); - this.settingsForm.addControl('whitelist', new FormControl((settings.whitelist || '').join(','), [])); this.settingsForm.addControl('firstLastPeopleNaming', new FormControl((settings.firstLastPeopleNaming), [])); this.settingsForm.addControl('personRoles', this.fb.group( Object.fromEntries( @@ -107,19 +103,6 @@ export class ManageMetadataSettingsComponent implements OnInit { ) )); - - if (settings.ageRatingMappings) { - Object.entries(settings.ageRatingMappings).forEach(([str, rating]) => { - this.addAgeRatingMapping(str, rating); - }); - } - - if (settings.fieldMappings) { - settings.fieldMappings.forEach(mapping => { - this.addFieldMapping(mapping); - }); - } - this.settingsForm.get('enablePeople')?.valueChanges.subscribe(enabled => { const firstLastControl = this.settingsForm.get('firstLastPeopleNaming'); if (enabled) { @@ -156,49 +139,17 @@ export class ManageMetadataSettingsComponent implements OnInit { } - breakTags(csString: string) { - if (csString) { - return csString.split(','); - } - - return []; - } - - packData(withFieldMappings: boolean = true) { const model = this.settingsForm.value; - // Convert FormArray to dictionary - const ageRatingMappings = this.ageRatingMappings.controls.reduce((acc, control) => { - // @ts-ignore - const { str, rating } = control.value; - if (str && rating) { - // @ts-ignore - acc[str] = parseInt(rating + '', 10) as AgeRating; - } - return acc; - }, {}); + const exp: MetadataMappingsExport = this.manageMetadataMappingsComponent.packData() - const fieldMappings = this.fieldMappings.controls.map((control) => { - const value = control.value as MetadataFieldMapping; - - return { - id: value.id, - sourceType: parseInt(value.sourceType + '', 10), - destinationType: parseInt(value.destinationType + '', 10), - sourceValue: value.sourceValue, - destinationValue: value.destinationValue, - excludeFromSource: value.excludeFromSource - } - }).filter(m => m.sourceValue.length > 0 && m.destinationValue.length > 0); - - // Translate blacklist string -> Array return { ...model, - ageRatingMappings, - fieldMappings: withFieldMappings ? fieldMappings : [], - blacklist: (model.blacklist || '').split(',').map((item: string) => item.trim()).filter((tag: string) => tag.length > 0), - whitelist: (model.whitelist || '').split(',').map((item: string) => item.trim()).filter((tag: string) => tag.length > 0), + ageRatingMappings: exp.ageRatingMappings, + fieldMappings: withFieldMappings ? exp.fieldMappings : [], + blacklist: exp.blacklist, + whitelist: exp.whitelist, personRoles: Object.entries(this.settingsForm.get('personRoles')!.value) .filter(([_, value]) => value) .map(([key, _]) => this.personRoles[parseInt(key.split('_')[1], 10)]), @@ -208,36 +159,6 @@ export class ManageMetadataSettingsComponent implements OnInit { } } - addAgeRatingMapping(str: string = '', rating: AgeRating = AgeRating.Unknown) { - const mappingGroup = this.fb.group({ - str: [str, Validators.required], - rating: [rating, Validators.required] - }); - // @ts-ignore - this.ageRatingMappings.push(mappingGroup); - } - - removeAgeRatingMappingRow(index: number) { - this.ageRatingMappings.removeAt(index); - } - - addFieldMapping(mapping: MetadataFieldMapping | null = null) { - const mappingGroup = this.fb.group({ - id: [mapping?.id || 0], - sourceType: [mapping?.sourceType || MetadataFieldType.Genre, Validators.required], - destinationType: [mapping?.destinationType || MetadataFieldType.Genre, Validators.required], - sourceValue: [mapping?.sourceValue || '', Validators.required], - destinationValue: [mapping?.destinationValue || ''], - excludeFromSource: [mapping?.excludeFromSource || false] - }); - - //@ts-ignore - this.fieldMappings.push(mappingGroup); - } - - removeFieldMappingRow(index: number) { - this.fieldMappings.removeAt(index); - } - + protected readonly SettingsTabId = SettingsTabId; } diff --git a/UI/Web/src/app/admin/manage-public-metadata-settings/manage-public-metadata-settings.component.html b/UI/Web/src/app/admin/manage-public-metadata-settings/manage-public-metadata-settings.component.html new file mode 100644 index 000000000..94c19d75a --- /dev/null +++ b/UI/Web/src/app/admin/manage-public-metadata-settings/manage-public-metadata-settings.component.html @@ -0,0 +1,44 @@ + + + @if (licenseService.hasValidLicenseSignal()) { + + } + + +
+ @if (settingsForm.get('enableExtendedMetadataProcessing'); as control) { + + +
+ +
+
+
+ } +
+ + +
+
+ +
{{t('export-tooltip')}}
+
+ +
+ +
{{t('import-tooltip')}}
+
+
+ +
+ +

{{t('tags-header')}}

+ + @if (settings) { + + } +
diff --git a/UI/Web/src/app/admin/manage-public-metadata-settings/manage-public-metadata-settings.component.scss b/UI/Web/src/app/admin/manage-public-metadata-settings/manage-public-metadata-settings.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/admin/manage-public-metadata-settings/manage-public-metadata-settings.component.ts b/UI/Web/src/app/admin/manage-public-metadata-settings/manage-public-metadata-settings.component.ts new file mode 100644 index 000000000..7ea08ce0d --- /dev/null +++ b/UI/Web/src/app/admin/manage-public-metadata-settings/manage-public-metadata-settings.component.ts @@ -0,0 +1,86 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + inject, + OnInit, + ViewChild +} from '@angular/core'; +import {SettingsService} from "../settings.service"; +import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; +import { + ManageMetadataMappingsComponent, + MetadataMappingsExport +} from "../manage-metadata-mappings/manage-metadata-mappings.component"; +import {MetadataSettings} from "../_models/metadata-settings"; +import {debounceTime, switchMap} from "rxjs"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {map} from "rxjs/operators"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {LicenseService} from "../../_services/license.service"; +import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; +import {RouterLink} from "@angular/router"; +import {SettingsTabId} from "../../sidenav/preference-nav/preference-nav.component"; + +/** + * Metadata settings for which a K+ license is not required + */ +@Component({ + selector: 'app-manage-public-metadata-settings', + imports: [ + ManageMetadataMappingsComponent, + TranslocoDirective, + ReactiveFormsModule, + RouterLink, + SettingSwitchComponent, + ], + templateUrl: './manage-public-metadata-settings.component.html', + styleUrl: './manage-public-metadata-settings.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ManagePublicMetadataSettingsComponent implements OnInit { + + @ViewChild(ManageMetadataMappingsComponent) manageMetadataMappingsComponent!: ManageMetadataMappingsComponent; + + private readonly settingService = inject(SettingsService); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly destroyRef = inject(DestroyRef); + protected readonly licenseService = inject(LicenseService); + + settingsForm: FormGroup = new FormGroup({}); + settings: MetadataSettings | undefined = undefined; + + ngOnInit(): void { + this.settingService.getMetadataSettings().subscribe(settings => { + this.settings = settings; + + this.settingsForm.addControl('enableExtendedMetadataProcessing', new FormControl(this.settings.enableExtendedMetadataProcessing, [])); + this.cdRef.markForCheck(); + }); + + this.settingsForm.valueChanges.pipe( + debounceTime(300), + takeUntilDestroyed(this.destroyRef), + map(_ => this.packData()), + switchMap((data) => this.settingService.updateMetadataSettings(data)), + ).subscribe(); + } + + packData() { + const model = Object.assign({}, this.settings); + const formValue = this.settingsForm.value; + + const exp: MetadataMappingsExport = this.manageMetadataMappingsComponent.packData() + + model.enableExtendedMetadataProcessing = formValue.enableExtendedMetadataProcessing; + model.ageRatingMappings = exp.ageRatingMappings; + model.fieldMappings = exp.fieldMappings; + model.whitelist = exp.whitelist; + model.blacklist = exp.blacklist; + + return model; + } + + protected readonly SettingsTabId = SettingsTabId; +} diff --git a/UI/Web/src/app/admin/settings.service.ts b/UI/Web/src/app/admin/settings.service.ts index dc490beb7..5f848d4f4 100644 --- a/UI/Web/src/app/admin/settings.service.ts +++ b/UI/Web/src/app/admin/settings.service.ts @@ -5,6 +5,8 @@ import { environment } from 'src/environments/environment'; import { TextResonse } from '../_types/text-response'; import { ServerSettings } from './_models/server-settings'; import {MetadataSettings} from "./_models/metadata-settings"; +import {MetadataMappingsExport} from "./manage-metadata-mappings/manage-metadata-mappings.component"; +import {FieldMappingsImportResult, ImportSettings} from "../_models/import-field-mappings"; /** * Used only for the Test Email Service call @@ -35,6 +37,14 @@ export class SettingsService { return this.http.post(this.baseUrl + 'settings/metadata-settings', model); } + importFieldMappings(data: MetadataMappingsExport, settings: ImportSettings) { + const body = { + data: data, + settings: settings, + } + return this.http.post(this.baseUrl + 'settings/import-field-mappings', body); + } + updateServerSettings(model: ServerSettings) { return this.http.post(this.baseUrl + 'settings', model); } diff --git a/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.ts b/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.ts index fe5892ebe..dabe43115 100644 --- a/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.ts +++ b/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.ts @@ -76,7 +76,7 @@ export class ImportCblComponent { fileUploadControl = new FormControl>(undefined, [ - FileUploadValidators.accept(['.cbl']), + FileUploadValidators.accept(['.cbl']) ]); uploadForm = new FormGroup({ diff --git a/UI/Web/src/app/settings/_components/settings/settings.component.html b/UI/Web/src/app/settings/_components/settings/settings.component.html index 3e3d4f144..2aaf2f03f 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.html +++ b/UI/Web/src/app/settings/_components/settings/settings.component.html @@ -49,6 +49,14 @@ } } + @defer (when fragment === SettingsTabId.ManageMetadata; prefetch on idle) { + @if (fragment === SettingsTabId.ManageMetadata) { +
+ +
+ } + } + @defer (when fragment === SettingsTabId.MediaIssues; prefetch on idle) { @if (fragment === SettingsTabId.MediaIssues) {
@@ -209,6 +217,14 @@ } } + @defer (when fragment === SettingsTabId.MappingsImport; prefetch on idle) { + @if (fragment === SettingsTabId.MappingsImport) { +
+ +
+ } + } + @defer (when fragment === SettingsTabId.MALStackImport; prefetch on idle) { @if(hasActiveLicense && fragment === SettingsTabId.MALStackImport) { diff --git a/UI/Web/src/app/settings/_components/settings/settings.component.ts b/UI/Web/src/app/settings/_components/settings/settings.component.ts index d470972d0..409499760 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.ts +++ b/UI/Web/src/app/settings/_components/settings/settings.component.ts @@ -55,6 +55,10 @@ import { import { ManageReadingProfilesComponent } from "../../../user-settings/manage-reading-profiles/manage-reading-profiles.component"; +import { + ManagePublicMetadataSettingsComponent +} from "../../../admin/manage-public-metadata-settings/manage-public-metadata-settings.component"; +import {ImportMappingsComponent} from "../../../admin/import-mappings/import-mappings.component"; @Component({ selector: 'app-settings', @@ -91,7 +95,9 @@ import { EmailHistoryComponent, ScrobblingHoldsComponent, ManageMetadataSettingsComponent, - ManageReadingProfilesComponent + ManageReadingProfilesComponent, + ManagePublicMetadataSettingsComponent, + ImportMappingsComponent ], templateUrl: './settings.component.html', styleUrl: './settings.component.scss', diff --git a/UI/Web/src/app/shared/_services/download.service.ts b/UI/Web/src/app/shared/_services/download.service.ts index 184f31094..d83eaea73 100644 --- a/UI/Web/src/app/shared/_services/download.service.ts +++ b/UI/Web/src/app/shared/_services/download.service.ts @@ -377,4 +377,21 @@ export class DownloadService { return null; } + + /** + * Download the given data as a json file + * @param data + * @param title may include the json file extension + */ + downloadObjectAsJson(data: any, title: string) { + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = title.endsWith('.json') ? title : title + '.json'; + a.click(); + URL.revokeObjectURL(url); + } } diff --git a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts index d76a84cc8..339753837 100644 --- a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts +++ b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts @@ -30,10 +30,12 @@ export enum SettingsTabId { Statistics = 'admin-statistics', MediaIssues = 'admin-media-issues', EmailHistory = 'admin-email-history', + ManageMetadata = 'admin-public-metadata', // Kavita+ KavitaPlusLicense = 'admin-kavitaplus', MALStackImport = 'mal-stack-import', + MappingsImport = 'admin-mappings-import', MatchedMetadata = 'admin-matched-metadata', ManageUserTokens = 'admin-manage-tokens', Metadata = 'admin-metadata', @@ -124,6 +126,7 @@ export class PreferenceNavComponent implements AfterViewInit { title: 'server-section-title', children: [ new SideNavItem(SettingsTabId.General, [Role.Admin]), + new SideNavItem(SettingsTabId.ManageMetadata, [Role.Admin]), new SideNavItem(SettingsTabId.Media, [Role.Admin]), new SideNavItem(SettingsTabId.Email, [Role.Admin]), new SideNavItem(SettingsTabId.Users, [Role.Admin]), @@ -135,6 +138,7 @@ export class PreferenceNavComponent implements AfterViewInit { title: 'import-section-title', children: [ new SideNavItem(SettingsTabId.CBLImport, [], undefined, [Role.ReadOnly]), + new SideNavItem(SettingsTabId.MappingsImport, [Role.Admin]), ] }, { diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index b858f5370..ac9f77fa0 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -744,6 +744,12 @@ }, "manage-metadata-settings": { + "k+-warning": "The settings below are shared with those found on the K+ page. You may change them in either location", + "export-settings": "Export", + "export-tooltip": "Export your blacklist and whitelist, age rating, and field mappings", + "export-file-name": "Kavita Metadata Settings Export.json", + "import-settings": "Import", + "import-tooltip": "Import your or someone else's settings", "description": "Kavita+ has the ability to download and write some limited metadata to the Database. This page allows for you to toggle what is in scope.", "enabled-label": "Enable Metadata Download", "enabled-tooltip": "Allow Kavita to download metadata and write to it's database.", @@ -776,25 +782,34 @@ "enable-genres-tooltip": "Allow Series Genres to be written.", "enable-tags-label": "Tags", "enable-tags-tooltip": "Allow Series Tags to be written.", + "enable-extended-metadata-processing-label": "Extended metadata processing", + "enable-extended-metadata-processing-tooltip": "Should genres and tags sourced from ComicInfo or added via the UI be processed using the blacklist, whitelist, age rating mappings, and field mappings.", "blacklist-label": "Blacklist Genres/Tags", "blacklist-tooltip": "Anything in this list will be removed from both Genre and Tag processing. This is a place to add genres/tags you do not want written. Ensure they are comma-separated.", "whitelist-label": "Whitelist Tags", "whitelist-tooltip": "Only allow a string in this list from being written for Tags. Ensure they are comma-separated.", "age-rating-mapping-title": "Age Rating Mapping", - "age-rating-mapping-description": "Any strings on the left if found in either Genre or Tags will set the Age Rating on the Series.", + "age-rating-mapping-description": "Any strings on the left, if found in either Genre or Tags, will set the Age Rating on the Series. Matching is normalized and case-insensitive, the highest age rating is used.", "genre": "Genre", "tag": "Tag", "remove-source-tag-label": "Remove Source Tag", "add-field-mapping-label": "Add Field Mapping", "add-age-rating-mapping-label": "Add Age Rating Mapping", + "remove-age-rating-mapping-label": "Remove age rating mapping", + "remove-field-mapping-label": "Remove field mapping", "field-mapping-title": "Field Mapping", - "field-mapping-description": "Setup rules for certain strings found in Genre/Tag field and map it to a new string in Genre/Tag and optionally remove it from the Source list. Only applicable when Genre/Tag are enabled to be written.", + "field-mapping-description": "Setup rules for certain strings found in Genre/Tag field and map it to a new string in Genre/Tag and optionally remove it from the Source list. Matching is normalized and case-insensitive, the first matching value is used. Only applicable when Genre/Tag are enabled to be written.", "first-last-name-label": "First Last Naming", "first-last-name-tooltip": "Ensure People's names are written First then Last", "person-roles-label": "Roles", "overrides-label": "Overrides", "overrides-description": "Allow Kavita to write over locked fields.", - "chapter-header": "Chapter Fields" + "chapter-header": "Chapter Fields", + "series-header": "Series Fields", + "people-header": "People", + "tags-header": "Tags", + "source-genre-tags-placeholder": "Source genre/tag", + "dest-genre-tags-placeholder": "Destination genre/tag" }, "book-line-overlay": { @@ -1713,6 +1728,7 @@ "admin-matched-metadata": "Matched Metadata", "admin-manage-tokens": "Manage User Tokens", "admin-metadata": "Manage Metadata", + "admin-mappings-import": "Metadata settings", "scrobble-holds": "Scrobble Holds", "account": "Account", "preferences": "Preferences", @@ -1724,7 +1740,8 @@ "theme": "Theme", "customize": "Customize", "cbl-import": "CBL Reading List", - "mal-stack-import": "MAL Stack" + "mal-stack-import": "MAL Stack", + "admin-public-metadata": "Manage Metadata" }, "collection-detail": { @@ -1930,6 +1947,45 @@ "no-data": "{{user-scrobble-history.no-data}}" }, + "import-mappings": { + "import-step": "Import", + "configure-step": "Configure", + "conflicts-step": "Resolve collisions", + "finalize-step": "Finalize", + "prev": "{{import-cbl-modal.prev}}", + "import": "{{import-cbl-modal.import}}", + "next": "{{import-cbl-modal.next}}", + "save": "{{common.save}}", + + "import-description": "Upload a file you, or someone else has exported to replace or merge with your current settings.", + "select-files-warning": "You must upload a json file to continue", + "invalid-file": "Failed to parse your file, check your input", + "file-no-valid-content": "Your import did not contain any meaningful data to continue with", + + "fields-to-import": "Settings", + "fields-to-import-tooltip": "Disable to skip a setting fully", + "whitelist-label": "Tags whitelist", + "blacklist-label": "Tags blacklist", + "age-ratings-label": "Age rating mappings", + "field-mappings-label": "Field mappings", + + "age-ratings-conflicts-tooltip": "Decide which age rating to use", + "field-mappings-conflicts-tooltip": "Decide which destination result to use", + + "import-mode-label": "Import mode", + "import-mode-tooltip": "Replace disregards your current settings, merge will try and resolve conflcits in the way you've configured", + "merge": "Merge", + "replace": "Replace", + + "resolution-label": "Conflict resolution", + "resolution-tooltip": "Decide how Kavita should handle conflicts", + "manual": "Manual", + "keep": "Keep", + "to-pick": "Choose resolution", + + "finalize-title": "Preview of your settings, press save to finish your import" + }, + "import-cbl-modal": { "close": "{{common.close}}", "title": "CBL Import", @@ -2423,7 +2479,11 @@ "invalid-password-reset-url": "Invalid reset password url", "delete-theme-in-use": "Theme is currently in use by at least one user, cannot delete", "theme-manual-upload": "There was an issue creating Theme from manual upload", - "theme-already-in-use": "Theme already exists by that name" + "theme-already-in-use": "Theme already exists by that name", + "import-fields": { + "non-unique-age-ratings": "Age rating mapping keys aren't unique, please correct your import file", + "non-unique-fields": "Field mappings do not have a unique id, please correct your import file" + } }, "metadata-builder": { @@ -3039,6 +3099,7 @@ "submit": "Submit", "email": "Email", "read": "Read", + "unknown": "Unknown", "loading": "Loading…", "username": "Username", "password": "Password",