From 53b13da0c904c3ce2c1f02a0d64059704dbedfb1 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Sat, 8 Feb 2025 15:37:12 -0600 Subject: [PATCH] More Metadata Stuff (#3537) --- API/Constants/CacheProfiles.cs | 4 + API/Controllers/SeriesController.cs | 27 +- API/Controllers/SettingsController.cs | 2 + API/Controllers/UploadController.cs | 2 +- .../Metadata/MetadataSettingsDto.cs | 31 + .../KavitaPlus/Metadata/SeriesCharacter.cs | 9 + API/DTOs/UpdateSeriesMetadataDto.cs | 2 +- API/Data/DataContext.cs | 19 +- .../ManualMigrateBlacklistTableToSeries.cs | 8 + ...0208200843_MoreMetadtaSettings.Designer.cs | 3398 +++++++++++++++++ .../20250208200843_MoreMetadtaSettings.cs | 61 + .../Migrations/DataContextModelSnapshot.cs | 18 +- .../ExternalSeriesMetadataRepository.cs | 1 + API/Data/Seed.cs | 1 + .../MetadataMatching/MetadataSettings.cs | 26 + API/Entities/Person/Person.cs | 46 +- API/Entities/Person/SeriesMetadataPeople.cs | 10 + .../ApplicationServiceExtensions.cs | 1 + .../QueryExtensions/QueryableExtensions.cs | 2 +- API/Helpers/AutoMapperProfiles.cs | 5 +- API/Services/Plus/ExternalMetadataService.cs | 163 +- API/Services/SeriesService.cs | 42 +- API/Services/Tasks/Metadata/CoverDbService.cs | 42 +- API/Services/Tasks/Scanner/ProcessSeries.cs | 18 +- UI/Web/src/app/_pipes/library-name.pipe.ts | 16 + .../app/_pipes/metadata-setting-filed.pipe.ts | 35 + .../admin/_models/metadata-setting-field.ts | 16 + .../app/admin/_models/metadata-settings.ts | 3 + .../manage-matched-metadata.component.html | 17 +- .../manage-matched-metadata.component.ts | 61 +- .../manage-metadata-settings.component.html | 74 +- .../manage-metadata-settings.component.ts | 20 +- UI/Web/src/assets/langs/en.json | 27 +- openapi.json | 45 +- 34 files changed, 4123 insertions(+), 129 deletions(-) create mode 100644 API/Data/Migrations/20250208200843_MoreMetadtaSettings.Designer.cs create mode 100644 API/Data/Migrations/20250208200843_MoreMetadtaSettings.cs create mode 100644 UI/Web/src/app/_pipes/library-name.pipe.ts create mode 100644 UI/Web/src/app/_pipes/metadata-setting-filed.pipe.ts create mode 100644 UI/Web/src/app/admin/_models/metadata-setting-field.ts diff --git a/API/Constants/CacheProfiles.cs b/API/Constants/CacheProfiles.cs index d70452cfe..4953c5568 100644 --- a/API/Constants/CacheProfiles.cs +++ b/API/Constants/CacheProfiles.cs @@ -23,4 +23,8 @@ public static class EasyCacheProfiles /// External Series metadata for Kavita+ recommendation /// public const string KavitaPlusExternalSeries = "kavita+externalSeries"; + /// + /// Match Series metadata for Kavita+ metadata download + /// + public const string KavitaPlusMatchSeries = "kavita+matchSeries"; } diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 62d9e350e..7def5c33f 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -19,11 +19,13 @@ using API.Helpers; using API.Services; using API.Services.Plus; using EasyCaching.Core; +using Hangfire; using Kavita.Common; using Kavita.Common.Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace API.Controllers; @@ -39,14 +41,17 @@ public class SeriesController : BaseApiController private readonly ILicenseService _licenseService; private readonly ILocalizationService _localizationService; private readonly IExternalMetadataService _externalMetadataService; + private readonly IHostEnvironment _environment; private readonly IEasyCachingProvider _externalSeriesCacheProvider; + private readonly IEasyCachingProvider _matchSeriesCacheProvider; private const string CacheKey = "externalSeriesData_"; + private const string MatchSeriesCacheKey = "matchSeries_"; public SeriesController(ILogger logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, ISeriesService seriesService, ILicenseService licenseService, IEasyCachingProviderFactory cachingProviderFactory, ILocalizationService localizationService, - IExternalMetadataService externalMetadataService) + IExternalMetadataService externalMetadataService, IHostEnvironment environment) { _logger = logger; _taskScheduler = taskScheduler; @@ -55,8 +60,10 @@ public class SeriesController : BaseApiController _licenseService = licenseService; _localizationService = localizationService; _externalMetadataService = externalMetadataService; + _environment = environment; _externalSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusExternalSeries); + _matchSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusMatchSeries); } /// @@ -501,7 +508,7 @@ public class SeriesController : BaseApiController /// /// /// This is cached for an hour - [ResponseCache(CacheProfileName = "Month", VaryByQueryKeys = new [] {"ageRating"})] + [ResponseCache(CacheProfileName = "Month", VaryByQueryKeys = ["ageRating"])] [HttpGet("age-rating")] public async Task> GetAgeRating(int ageRating) { @@ -625,7 +632,17 @@ public class SeriesController : BaseApiController [HttpPost("match")] public async Task>> MatchSeries(MatchSeriesDto dto) { - return Ok(await _externalMetadataService.MatchSeries(dto)); + var cacheKey = $"{MatchSeriesCacheKey}-{dto.SeriesId}-{dto.Query}"; + var results = await _matchSeriesCacheProvider.GetAsync>(cacheKey); + if (results.HasValue && !_environment.IsDevelopment()) + { + return Ok(results.Value); + } + + var ret = await _externalMetadataService.MatchSeries(dto); + await _matchSeriesCacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromMinutes(5)); + + return Ok(ret); } /// @@ -635,9 +652,9 @@ public class SeriesController : BaseApiController /// /// [HttpPost("update-match")] - public async Task UpdateSeriesMatch([FromQuery] int seriesId, [FromQuery] int aniListId) + public ActionResult UpdateSeriesMatch([FromQuery] int seriesId, [FromQuery] int aniListId) { - await _externalMetadataService.FixSeriesMatch(seriesId, aniListId); + BackgroundJob.Enqueue(() => _externalMetadataService.FixSeriesMatch(seriesId, aniListId)); return Ok(); } diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 1e7a67052..e2d77d674 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -560,6 +560,7 @@ public class SettingsController : BaseApiController var existingMetadataSetting = await _unitOfWork.SettingsRepository.GetMetadataSettings(); existingMetadataSetting.Enabled = dto.Enabled; existingMetadataSetting.EnableSummary = dto.EnableSummary; + existingMetadataSetting.EnableLocalizedName = dto.EnableLocalizedName; existingMetadataSetting.EnablePublicationStatus = dto.EnablePublicationStatus; existingMetadataSetting.EnableRelationships = dto.EnableRelationships; existingMetadataSetting.EnablePeople = dto.EnablePeople; @@ -573,6 +574,7 @@ public class SettingsController : BaseApiController existingMetadataSetting.Blacklist = dto.Blacklist.DistinctBy(d => d.ToNormalized()).ToList() ?? []; existingMetadataSetting.Whitelist = dto.Whitelist.DistinctBy(d => d.ToNormalized()).ToList() ?? []; + existingMetadataSetting.Overrides = dto.Overrides.ToList() ?? []; // Handle Field Mappings if (dto.FieldMappings != null) diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index a119ad1f7..bea9771ce 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -498,7 +498,7 @@ public class UploadController : BaseApiController var person = await _unitOfWork.PersonRepository.GetPersonById(uploadFileDto.Id); if (person == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-doesnt-exist")); - await _coverDbService.SetPersonCoverImage(person, uploadFileDto.Url, true); + await _coverDbService.SetPersonCoverByUrl(person, uploadFileDto.Url, true); return Ok(); } catch (Exception e) diff --git a/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs b/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs index 3dababa38..3c8687f49 100644 --- a/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs +++ b/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs @@ -1,9 +1,11 @@ using System.Collections.Generic; using API.Entities; using API.Entities.Enums; +using NotImplementedException = System.NotImplementedException; namespace API.DTOs.KavitaPlus.Metadata; + public class MetadataSettingsDto { /// @@ -35,6 +37,10 @@ public class MetadataSettingsDto /// Allow setting the Localized name /// public bool EnableLocalizedName { get; set; } + /// + /// Allow setting the cover image + /// + public bool EnableCoverImage { get; set; } // Need to handle the Genre/tags stuff public bool EnableGenres { get; set; } = true; @@ -54,6 +60,10 @@ public class MetadataSettingsDto /// 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 @@ -67,4 +77,25 @@ public class MetadataSettingsDto /// Which Roles to allow metadata downloading for /// public List PersonRoles { get; set; } + + + /// + /// Override list contains this field + /// + /// + /// + public bool HasOverride(MetadataSettingField field) + { + return Overrides.Contains(field); + } + + /// + /// If this Person role is allowed to be written + /// + /// + /// + public bool IsPersonAllowed(PersonRole character) + { + return PersonRoles.Contains(character); + } } diff --git a/API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs b/API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs index 0f3fdb71e..4cb8a54ee 100644 --- a/API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs +++ b/API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs @@ -1,9 +1,18 @@ namespace API.DTOs.KavitaPlus.Metadata; +public enum CharacterRole +{ + Main = 0, + Supporting = 1, + Background = 2 +} + + public class SeriesCharacter { public string Name { get; set; } public required string Description { get; set; } public required string Url { get; set; } public string? ImageUrl { get; set; } + public CharacterRole Role { get; set; } } diff --git a/API/DTOs/UpdateSeriesMetadataDto.cs b/API/DTOs/UpdateSeriesMetadataDto.cs index 719a9459a..75150b3fa 100644 --- a/API/DTOs/UpdateSeriesMetadataDto.cs +++ b/API/DTOs/UpdateSeriesMetadataDto.cs @@ -2,5 +2,5 @@ public class UpdateSeriesMetadataDto { - public SeriesMetadataDto SeriesMetadata { get; set; } = default!; + public SeriesMetadataDto SeriesMetadata { get; set; } = null!; } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 728cf29c2..7ab71c992 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -204,11 +204,15 @@ public sealed class DataContext : IdentityDbContext smp.PersonId) .OnDelete(DeleteBehavior.Cascade); + builder.Entity() + .Property(b => b.OrderWeight) + .HasDefaultValue(0); + builder.Entity() .Property(x => x.AgeRatingMappings) .HasConversion( v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new Dictionary() ); // Ensure blacklist is stored as a JSON array @@ -216,13 +220,19 @@ public sealed class DataContext : IdentityDbContext x.Blacklist) .HasConversion( v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List() ); builder.Entity() .Property(x => x.Whitelist) .HasConversion( v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List() + ); + builder.Entity() + .Property(x => x.Overrides) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List() ); // Configure one-to-many relationship @@ -235,6 +245,9 @@ public sealed class DataContext : IdentityDbContext() .Property(b => b.Enabled) .HasDefaultValue(true); + builder.Entity() + .Property(b => b.EnableCoverImage) + .HasDefaultValue(true); } #nullable enable diff --git a/API/Data/ManualMigrations/v0.8.5/ManualMigrateBlacklistTableToSeries.cs b/API/Data/ManualMigrations/v0.8.5/ManualMigrateBlacklistTableToSeries.cs index 8fd7ec7fd..60fd170fd 100644 --- a/API/Data/ManualMigrations/v0.8.5/ManualMigrateBlacklistTableToSeries.cs +++ b/API/Data/ManualMigrations/v0.8.5/ManualMigrateBlacklistTableToSeries.cs @@ -29,10 +29,18 @@ public static class ManualMigrateBlacklistTableToSeries .Include(s => s.Series.ExternalSeriesMetadata) .Select(s => s.Series) .ToListAsync(); + foreach (var series in blacklistedSeries) { series.IsBlacklisted = true; series.ExternalSeriesMetadata ??= new ExternalSeriesMetadata() { SeriesId = series.Id }; + + if (series.ExternalSeriesMetadata.AniListId > 0) + { + series.IsBlacklisted = false; + logger.LogInformation("{SeriesName} was in Blacklist table, but has valid AniList Id, not blacklisting", series.Name); + } + context.Series.Entry(series).State = EntityState.Modified; } // Remove everything in SeriesBlacklist (it will be removed in another migration) diff --git a/API/Data/Migrations/20250208200843_MoreMetadtaSettings.Designer.cs b/API/Data/Migrations/20250208200843_MoreMetadtaSettings.Designer.cs new file mode 100644 index 000000000..9aaa63101 --- /dev/null +++ b/API/Data/Migrations/20250208200843_MoreMetadtaSettings.Designer.cs @@ -0,0 +1,3398 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250208200843_MoreMetadtaSettings")] + partial class MoreMetadtaSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person", 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.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.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + 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.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250208200843_MoreMetadtaSettings.cs b/API/Data/Migrations/20250208200843_MoreMetadtaSettings.cs new file mode 100644 index 000000000..70e42cd11 --- /dev/null +++ b/API/Data/Migrations/20250208200843_MoreMetadtaSettings.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class MoreMetadtaSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "KavitaPlusConnection", + table: "SeriesMetadataPeople", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "OrderWeight", + table: "SeriesMetadataPeople", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "EnableCoverImage", + table: "MetadataSettings", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "Overrides", + table: "MetadataSettings", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "KavitaPlusConnection", + table: "SeriesMetadataPeople"); + + migrationBuilder.DropColumn( + name: "OrderWeight", + table: "SeriesMetadataPeople"); + + migrationBuilder.DropColumn( + name: "EnableCoverImage", + table: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "Overrides", + table: "MetadataSettings"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index d969df273..3fc2ec3da 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -1652,6 +1652,11 @@ namespace API.Data.Migrations b.Property("Blacklist") .HasColumnType("TEXT"); + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.Property("EnableGenres") .HasColumnType("INTEGER"); @@ -1684,10 +1689,13 @@ namespace API.Data.Migrations b.Property("FirstLastPeopleNaming") .HasColumnType("INTEGER"); + b.Property("Overrides") + .HasColumnType("TEXT"); + b.PrimitiveCollection("PersonRoles") .HasColumnType("TEXT"); - b.PrimitiveCollection("Whitelist") + b.Property("Whitelist") .HasColumnType("TEXT"); b.HasKey("Id"); @@ -2114,6 +2122,14 @@ namespace API.Data.Migrations 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"); diff --git a/API/Data/Repositories/ExternalSeriesMetadataRepository.cs b/API/Data/Repositories/ExternalSeriesMetadataRepository.cs index 050038e86..d7b6c11bf 100644 --- a/API/Data/Repositories/ExternalSeriesMetadataRepository.cs +++ b/API/Data/Repositories/ExternalSeriesMetadataRepository.cs @@ -225,6 +225,7 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor { return await _context.Series .Include(s => s.Library) + .Include(s => s.ExternalSeriesMetadata) .Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type)) .FilterMatchState(filter.MatchStateOption) .OrderBy(s => s.NormalizedName) diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index e6fbb8990..d4f8bbb34 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -309,6 +309,7 @@ public static class Seed EnableGenres = true, EnableLocalizedName = false, FirstLastPeopleNaming = true, + EnableCoverImage = true, PersonRoles = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character] }; await context.MetadataSettings.AddAsync(existing); diff --git a/API/Entities/MetadataMatching/MetadataSettings.cs b/API/Entities/MetadataMatching/MetadataSettings.cs index 76de505c6..7f982d6b1 100644 --- a/API/Entities/MetadataMatching/MetadataSettings.cs +++ b/API/Entities/MetadataMatching/MetadataSettings.cs @@ -1,8 +1,25 @@ using System.Collections.Generic; +using System.Linq; using API.Entities.Enums; namespace API.Entities; +/// +/// Represents which field that can be written to as an override when already locked +/// +public enum MetadataSettingField +{ + Summary = 1, + PublicationStatus = 2, + StartDate = 3, + Genres = 4, + Tags = 5, + LocalizedName = 6, + Covers = 7, + AgeRating = 8, + People = 9 +} + /// /// Handles the metadata settings for Kavita+ /// @@ -38,6 +55,10 @@ public class MetadataSettings /// Allow setting the Localized name /// public bool EnableLocalizedName { get; set; } + /// + /// Allow setting the cover image + /// + public bool EnableCoverImage { get; set; } // Need to handle the Genre/tags stuff public bool EnableGenres { get; set; } = true; @@ -58,6 +79,11 @@ public class MetadataSettings /// 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 /// diff --git a/API/Entities/Person/Person.cs b/API/Entities/Person/Person.cs index ba40b5f82..13f8a9b77 100644 --- a/API/Entities/Person/Person.cs +++ b/API/Entities/Person/Person.cs @@ -2,6 +2,7 @@ using API.Entities.Enums; using API.Entities.Interfaces; using API.Entities.Metadata; +using API.Services.Plus; namespace API.Entities; @@ -18,29 +19,30 @@ public class Person : IHasCoverImage public string PrimaryColor { get; set; } public string SecondaryColor { get; set; } - public string Description { get; set; } - /// - /// ASIN for person - /// - /// Can be used for Amazon author lookup - public string? Asin { get; set; } + public string Description { get; set; } + /// + /// ASIN for person + /// + /// Can be used for Amazon author lookup + public string? Asin { get; set; } + + /// + /// https://anilist.co/staff/{AniListId}/ + /// + /// Kavita+ Only + public int AniListId { get; set; } = 0; + /// + /// https://myanimelist.net/people/{MalId}/ + /// https://myanimelist.net/character/{MalId}/CharacterName + /// + /// Kavita+ Only + public long MalId { get; set; } = 0; + /// + /// https://hardcover.app/authors/{HardcoverId} + /// + /// Kavita+ Only + public string? HardcoverId { get; set; } - /// - /// https://anilist.co/staff/{AniListId}/ - /// - /// Kavita+ Only - public int AniListId { get; set; } = 0; - /// - /// https://myanimelist.net/people/{MalId}/ - /// https://myanimelist.net/character/{MalId}/CharacterName - /// - /// Kavita+ Only - public long MalId { get; set; } = 0; - /// - /// https://hardcover.app/authors/{HardcoverId} - /// - /// Kavita+ Only - public string? HardcoverId { get; set; } /// /// https://metron.cloud/creator/{slug}/ /// diff --git a/API/Entities/Person/SeriesMetadataPeople.cs b/API/Entities/Person/SeriesMetadataPeople.cs index dd188ddf0..1f5dd2f5b 100644 --- a/API/Entities/Person/SeriesMetadataPeople.cs +++ b/API/Entities/Person/SeriesMetadataPeople.cs @@ -1,5 +1,6 @@ using API.Entities.Enums; using API.Entities.Metadata; +using API.Services.Plus; namespace API.Entities; @@ -11,5 +12,14 @@ public class SeriesMetadataPeople public int PersonId { get; set; } public virtual Person Person { get; set; } + /// + /// The source of this connection. If not Kavita, this implies Metadata Download linked this and it can be removed between matches + /// + public bool KavitaPlusConnection { get; set; } = false; + /// + /// A weight that allows lower numbers to sort first + /// + public int OrderWeight { get; set; } + public required PersonRole Role { get; set; } } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index d775c7cd5..adabb436e 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -90,6 +90,7 @@ public static class ApplicationServiceExtensions options.UseInMemory(EasyCacheProfiles.KavitaPlusExternalSeries); options.UseInMemory(EasyCacheProfiles.License); options.UseInMemory(EasyCacheProfiles.LicenseInfo); + options.UseInMemory(EasyCacheProfiles.KavitaPlusMatchSeries); }); services.AddMemoryCache(options => diff --git a/API/Extensions/QueryExtensions/QueryableExtensions.cs b/API/Extensions/QueryExtensions/QueryableExtensions.cs index 324d67291..d6fef254e 100644 --- a/API/Extensions/QueryExtensions/QueryableExtensions.cs +++ b/API/Extensions/QueryExtensions/QueryableExtensions.cs @@ -293,7 +293,7 @@ public static class QueryableExtensions .Where(s => s.ExternalSeriesMetadata != null && s.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue && !s.IsBlacklisted), MatchStateOption.NotMatched => query. Include(s => s.ExternalSeriesMetadata) - .Where(s => (s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc == DateTime.MinValue) && !s.IsBlacklisted), + .Where(s => (s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc == DateTime.MinValue) && !s.IsBlacklisted && !s.DontMatch), MatchStateOption.Error => query.Where(s => s.IsBlacklisted), MatchStateOption.DontMatch => query.Where(s => s.DontMatch), _ => query diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 9efb1268b..6bf3b3fc2 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -121,8 +121,8 @@ public class AutoMapperProfiles : Profile // Map Characters .ForMember(dest => dest.Characters, opt => opt.MapFrom(src => src.People .Where(cp => cp.Role == PersonRole.Character) - .Select(cp => cp.Person) - .OrderBy(p => p.NormalizedName))) + .OrderBy(cp => cp.OrderWeight) + .Select(cp => cp.Person))) // Map Pencillers .ForMember(dest => dest.Pencillers, opt => opt.MapFrom(src => src.People .Where(cp => cp.Role == PersonRole.Penciller) @@ -369,6 +369,7 @@ public class AutoMapperProfiles : Profile CreateMap() .ForMember(dest => dest.Blacklist, opt => opt.MapFrom(src => src.Blacklist ?? new List())) .ForMember(dest => dest.Whitelist, opt => opt.MapFrom(src => src.Whitelist ?? new List())) + .ForMember(dest => dest.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List())) .ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary())); diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index 1858480c1..055a5fc28 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -45,7 +45,7 @@ public interface IExternalMetadataService /// /// /// - Task GetNewSeriesData(int seriesId, LibraryType libraryType); + Task FetchSeriesMetadata(int seriesId, LibraryType libraryType); Task> GetStacksForUser(int userId); Task> MatchSeries(MatchSeriesDto dto); @@ -118,7 +118,7 @@ public class ExternalMetadataService : IExternalMetadataService foreach (var seriesId in ids) { var libraryType = libTypes[seriesId]; - await GetNewSeriesData(seriesId, libraryType); + await FetchSeriesMetadata(seriesId, libraryType); await Task.Delay(1500); count++; } @@ -131,7 +131,7 @@ public class ExternalMetadataService : IExternalMetadataService /// /// /// - public async Task GetNewSeriesData(int seriesId, LibraryType libraryType) + public async Task FetchSeriesMetadata(int seriesId, LibraryType libraryType) { if (!IsPlusEligible(libraryType)) return; if (!await _licenseService.HasActiveLicense()) return; @@ -146,8 +146,9 @@ public class ExternalMetadataService : IExternalMetadataService } _logger.LogDebug("Prefetching Kavita+ data for Series {SeriesId}", seriesId); + // Prefetch SeriesDetail data - var metadata = await GetSeriesDetailPlus(seriesId, libraryType); + await GetSeriesDetailPlus(seriesId, libraryType); } @@ -211,7 +212,7 @@ public class ExternalMetadataService : IExternalMetadataService Format = series.Format == MangaFormat.Epub ? PlusMediaFormat.LightNovel : PlusMediaFormat.Manga, Query = dto.Query, SeriesName = series.Name, - AlternativeNames = altNames, + AlternativeNames = altNames.Where(s => !string.IsNullOrEmpty(s)).ToList(), Year = series.Metadata.ReleaseYear, AniListId = potentialAnilistId ?? ScrobblingService.GetAniListId(series), MalId = potentialMalId ?? ScrobblingService.GetMalId(series), @@ -403,7 +404,7 @@ public class ExternalMetadataService : IExternalMetadataService var result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail") .WithKavitaPlusHeaders(license) .PostJsonAsync(data) - .ReceiveJson(); + .ReceiveJson(); // This returns an AniListSeries and Match returns ExternalSeriesDto // Clear out existing results @@ -446,6 +447,8 @@ public class ExternalMetadataService : IExternalMetadataService var madeMetadataModification = false; if (result.Series != null && series.Library.AllowMetadataMatching) { + externalSeriesMetadata.Series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + madeMetadataModification = await WriteExternalMetadataToSeries(result.Series, seriesId); if (madeMetadataModification) { @@ -499,21 +502,41 @@ public class ExternalMetadataService : IExternalMetadataService { var settings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); if (!settings.Enabled) return false; + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Related); if (series == null) return false; + var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser(); _logger.LogInformation("Writing External metadata to Series {SeriesName}", series.Name); var madeModification = false; - if (!series.Metadata.SummaryLocked && string.IsNullOrEmpty(series.Metadata.Summary) && settings.EnableSummary) + if (settings.EnableLocalizedName && (!series.LocalizedNameLocked || settings.HasOverride(MetadataSettingField.LocalizedName))) + { + // We need to make the best appropriate guess + if (externalMetadata.Name == series.Name) + { + // Choose closest (usually last) synonym + series.LocalizedName = externalMetadata.Synonyms.Last(); + } + else + { + series.LocalizedName = externalMetadata.Name; + } + + madeModification = true; + } + + if (settings.EnableSummary && (!series.Metadata.SummaryLocked || + settings.HasOverride(MetadataSettingField.Summary))) { series.Metadata.Summary = CleanSummary(externalMetadata.Summary); madeModification = true; } - if (settings.EnableStartDate && !series.Metadata.ReleaseYearLocked && externalMetadata.StartDate.HasValue) + if (settings.EnableStartDate && externalMetadata.StartDate.HasValue && (!series.Metadata.ReleaseYearLocked || + settings.HasOverride(MetadataSettingField.StartDate))) { series.Metadata.ReleaseYear = externalMetadata.StartDate.Value.Year; madeModification = true; @@ -543,7 +566,7 @@ public class ExternalMetadataService : IExternalMetadataService .Where(g => !settings.Blacklist.Contains(g)) .ToList(); - if (settings.EnableGenres && !series.Metadata.GenresLocked && processedGenres.Count > 0) + if (settings.EnableGenres && processedGenres.Count > 0 && (!series.Metadata.GenresLocked || settings.HasOverride(MetadataSettingField.Genres))) { _logger.LogDebug("Found {GenreCount} genres for {SeriesName}", processedGenres.Count, series.Name); var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(processedGenres.Select(Parser.Normalize))).ToList(); @@ -578,7 +601,7 @@ public class ExternalMetadataService : IExternalMetadataService .ToList(); // Set the tags for the series and ensure they are in the DB - if (settings.EnableTags && !series.Metadata.TagsLocked && processedTags.Count > 0) + if (settings.EnableTags && processedTags.Count > 0 && (!series.Metadata.TagsLocked || settings.HasOverride(MetadataSettingField.Tags))) { _logger.LogDebug("Found {TagCount} tags for {SeriesName}", processedTags.Count, series.Name); var allTags = (await _unitOfWork.TagRepository.GetAllTagsByNameAsync(processedTags.Select(Parser.Normalize))) @@ -596,7 +619,7 @@ public class ExternalMetadataService : IExternalMetadataService #region Age Rating - if (!series.Metadata.AgeRatingLocked) + if (!series.Metadata.AgeRatingLocked || settings.HasOverride(MetadataSettingField.AgeRating)) { try { @@ -644,64 +667,92 @@ public class ExternalMetadataService : IExternalMetadataService // Roles: Character Design, Story, Art - var allWriters = externalMetadata.Staff + var upstreamWriters = externalMetadata.Staff .Where(s => s.Role is "Story" or "Story & Art") .ToList(); - var writers = allWriters + var writers = upstreamWriters .Select(w => new PersonDto() { Name = w.Name, AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListStaffWebsite), Description = CleanSummary(w.Description), }) - .Concat(series.Metadata.People.Where(p => p.Role == PersonRole.Writer).Select(p => _mapper.Map(p))) + .Concat(series.Metadata.People + .Where(p => p.Role == PersonRole.Writer) + .Where(p => !p.KavitaPlusConnection) + .Select(p => _mapper.Map(p.Person)) + ) .DistinctBy(p => Parser.Normalize(p.Name)) .ToList(); // NOTE: PersonRoles can be a hashset - if (!series.Metadata.WriterLocked && writers.Count > 0 && settings.PersonRoles.Contains(PersonRole.Writer)) + if (writers.Count > 0 && settings.IsPersonAllowed(PersonRole.Writer) && (!series.Metadata.WriterLocked || settings.HasOverride(MetadataSettingField.People))) { await SeriesService.HandlePeopleUpdateAsync(series.Metadata, writers, PersonRole.Writer, _unitOfWork); + foreach (var person in series.Metadata.People.Where(p => p.Role == PersonRole.Writer)) + { + var meta = upstreamWriters.FirstOrDefault(c => c.Name == person.Person.Name); + person.OrderWeight = 0; + if (meta != null) + { + person.KavitaPlusConnection = true; + } + } + _unitOfWork.SeriesRepository.Update(series); await _unitOfWork.CommitAsync(); - await DownloadAndSetCovers(allWriters); + await DownloadAndSetCovers(upstreamWriters); madeModification = true; } - var allArtists = externalMetadata.Staff + var upstreamArtists = externalMetadata.Staff .Where(s => s.Role is "Art" or "Story & Art") .ToList(); - var artists = allArtists + var artists = upstreamArtists .Select(w => new PersonDto() { Name = w.Name, AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListStaffWebsite), Description = CleanSummary(w.Description), }) - .Concat(series.Metadata.People.Where(p => p.Role == PersonRole.CoverArtist).Select(p => _mapper.Map(p))) + .Concat(series.Metadata.People + .Where(p => p.Role == PersonRole.CoverArtist) + .Where(p => !p.KavitaPlusConnection) + .Select(p => _mapper.Map(p.Person)) + ) .DistinctBy(p => Parser.Normalize(p.Name)) .ToList(); - if (!series.Metadata.CoverArtistLocked && artists.Count > 0 && settings.PersonRoles.Contains(PersonRole.CoverArtist)) + if (artists.Count > 0 && settings.IsPersonAllowed(PersonRole.CoverArtist) && (!series.Metadata.CoverArtistLocked || settings.HasOverride(MetadataSettingField.People))) { await SeriesService.HandlePeopleUpdateAsync(series.Metadata, artists, PersonRole.CoverArtist, _unitOfWork); + foreach (var person in series.Metadata.People.Where(p => p.Role == PersonRole.CoverArtist)) + { + var meta = upstreamArtists.FirstOrDefault(c => c.Name == person.Person.Name); + person.OrderWeight = 0; + if (meta != null) + { + person.KavitaPlusConnection = true; + } + } // Download the image and save it _unitOfWork.SeriesRepository.Update(series); await _unitOfWork.CommitAsync(); - await DownloadAndSetCovers(allArtists); + await DownloadAndSetCovers(upstreamArtists); madeModification = true; } - if (externalMetadata.Characters != null && settings.PersonRoles.Contains(PersonRole.Character)) + if (externalMetadata.Characters != null && settings.IsPersonAllowed(PersonRole.Character) && (!series.Metadata.CharacterLocked || + settings.HasOverride(MetadataSettingField.People))) { var characters = externalMetadata.Characters .Select(w => new PersonDto() @@ -710,27 +761,50 @@ public class ExternalMetadataService : IExternalMetadataService AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListCharacterWebsite), Description = CleanSummary(w.Description), }) - .Concat(series.Metadata.People.Where(p => p.Role == PersonRole.Character).Select(p => _mapper.Map(p))) + .Concat(series.Metadata.People + .Where(p => p.Role == PersonRole.Character) + // Need to ensure existing people are retained, but we overwrite anything from a bad match + .Where(p => !p.KavitaPlusConnection) + .Select(p => _mapper.Map(p.Person)) + ) .DistinctBy(p => Parser.Normalize(p.Name)) .ToList(); - if (!series.Metadata.CharacterLocked && characters.Count > 0) + if (characters.Count > 0) { await SeriesService.HandlePeopleUpdateAsync(series.Metadata, characters, PersonRole.Character, _unitOfWork); + foreach (var spPerson in series.Metadata.People.Where(p => p.Role == PersonRole.Character)) + { + // Set a sort order based on their role + var characterMeta = externalMetadata.Characters?.FirstOrDefault(c => c.Name == spPerson.Person.Name); + spPerson.OrderWeight = 0; + if (characterMeta != null) + { + spPerson.KavitaPlusConnection = true; + + spPerson.OrderWeight = characterMeta.Role switch + { + CharacterRole.Main => 0, + CharacterRole.Supporting => 1, + CharacterRole.Background => 2, + _ => 99 // Default for unknown roles + }; + } + } // Download the image and save it _unitOfWork.SeriesRepository.Update(series); await _unitOfWork.CommitAsync(); - foreach (var character in externalMetadata.Characters) + foreach (var character in externalMetadata.Characters ?? []) { var aniListId = ScrobblingService.ExtractId(character.Url, ScrobblingService.AniListCharacterWebsite); if (aniListId <= 0) continue; var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId); if (person != null && !string.IsNullOrEmpty(character.ImageUrl) && string.IsNullOrEmpty(person.CoverImage)) { - await _coverDbService.SetPersonCoverImage(person, character.ImageUrl, false); + await _coverDbService.SetPersonCoverByUrl(person, character.ImageUrl, false); } } @@ -743,7 +817,8 @@ public class ExternalMetadataService : IExternalMetadataService #region Publication Status - if (!series.Metadata.PublicationStatusLocked && settings.EnablePublicationStatus) + if (settings.EnablePublicationStatus && (!series.Metadata.PublicationStatusLocked || + settings.HasOverride(MetadataSettingField.PublicationStatus))) { try { @@ -765,7 +840,6 @@ public class ExternalMetadataService : IExternalMetadataService if (settings.EnableRelationships && externalMetadata.Relations != null && defaultAdmin != null) { - foreach (var relation in externalMetadata.Relations) { var relatedSeries = await _unitOfWork.SeriesRepository.GetSeriesByAnyName( @@ -817,9 +891,20 @@ public class ExternalMetadataService : IExternalMetadataService } #endregion + #region Series Cover + + // This must not allow cover image locked to be off after downloading, else it will call every time a match is hit + if (!string.IsNullOrEmpty(externalMetadata.CoverUrl) && (!series.CoverImageLocked || settings.HasOverride(MetadataSettingField.Covers))) + { + await DownloadSeriesCovers(series, externalMetadata.CoverUrl); + } + + #endregion + return madeModification; } + private static RelationKind GetReverseRelation(RelationKind relation) { return relation switch @@ -830,6 +915,18 @@ public class ExternalMetadataService : IExternalMetadataService }; } + private async Task DownloadSeriesCovers(Series series, string coverUrl) + { + try + { + await _coverDbService.SetSeriesCoverByUrl(series, coverUrl, false); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception downloading cover image for Series {SeriesName} ({SeriesId})", series.Name, series.Id); + } + } + private async Task DownloadAndSetCovers(List people) { foreach (var staff in people) @@ -839,7 +936,7 @@ public class ExternalMetadataService : IExternalMetadataService var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId.Value); if (person != null && !string.IsNullOrEmpty(staff.ImageUrl) && string.IsNullOrEmpty(person.CoverImage)) { - await _coverDbService.SetPersonCoverImage(person, staff.ImageUrl, false); + await _coverDbService.SetPersonCoverByUrl(person, staff.ImageUrl, false); } } } @@ -929,7 +1026,13 @@ public class ExternalMetadataService : IExternalMetadataService return mapping.DestinationValue ?? (mapping.ExcludeFromSource ? null : value); } - private static AgeRating DetermineAgeRating(IEnumerable values, Dictionary mappings) + /// + /// Returns the highest age rating from all tags/genres based on user-supplied mappings + /// + /// A combo of all tags/genres + /// + /// + public static AgeRating DetermineAgeRating(IEnumerable values, Dictionary mappings) { // Find highest age rating from mappings mappings ??= new Dictionary(); diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 2f1795999..37051f98d 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -121,12 +121,6 @@ public class SeriesService : ISeriesService series.Metadata ??= new SeriesMetadataBuilder() .Build(); - if (series.Metadata.AgeRating != updateSeriesMetadataDto.SeriesMetadata.AgeRating) - { - series.Metadata.AgeRating = updateSeriesMetadataDto.SeriesMetadata.AgeRating; - series.Metadata.AgeRatingLocked = true; - } - if (NumberHelper.IsValidYear(updateSeriesMetadataDto.SeriesMetadata.ReleaseYear) && series.Metadata.ReleaseYear != updateSeriesMetadataDto.SeriesMetadata.ReleaseYear) { series.Metadata.ReleaseYear = updateSeriesMetadataDto.SeriesMetadata.ReleaseYear; @@ -173,7 +167,7 @@ public class SeriesService : ISeriesService updateSeriesMetadataDto.SeriesMetadata.Genres.Count != 0) { var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(updateSeriesMetadataDto.SeriesMetadata.Genres.Select(t => Parser.Normalize(t.Title)))).ToList(); - series.Metadata.Genres ??= new List(); + series.Metadata.Genres ??= []; GenreHelper.UpdateGenreList(updateSeriesMetadataDto.SeriesMetadata?.Genres, series, allGenres, genre => { series.Metadata.Genres.Add(genre); @@ -181,7 +175,7 @@ public class SeriesService : ISeriesService } else { - series.Metadata.Genres = new List(); + series.Metadata.Genres = []; } @@ -190,7 +184,7 @@ public class SeriesService : ISeriesService var allTags = (await _unitOfWork.TagRepository .GetAllTagsByNameAsync(updateSeriesMetadataDto.SeriesMetadata.Tags.Select(t => Parser.Normalize(t.Title)))) .ToList(); - series.Metadata.Tags ??= new List(); + series.Metadata.Tags ??= []; TagHelper.UpdateTagList(updateSeriesMetadataDto.SeriesMetadata?.Tags, series, allTags, tag => { series.Metadata.Tags.Add(tag); @@ -198,14 +192,33 @@ public class SeriesService : ISeriesService } else { - series.Metadata.Tags = new List(); + series.Metadata.Tags = []; + } + + if (series.Metadata.AgeRating != updateSeriesMetadataDto.SeriesMetadata?.AgeRating) + { + series.Metadata.AgeRating = updateSeriesMetadataDto.SeriesMetadata?.AgeRating ?? AgeRating.Unknown; + series.Metadata.AgeRatingLocked = true; + } + else + { + if (!series.Metadata.AgeRatingLocked) + { + 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) + { + series.Metadata.AgeRating = updatedRating; + } + } } if (updateSeriesMetadataDto.SeriesMetadata != null) { if (PersonHelper.HasAnyPeople(updateSeriesMetadataDto.SeriesMetadata)) { - series.Metadata.People ??= new List(); + series.Metadata.People ??= []; // Writers if (!series.Metadata.WriterLocked) @@ -279,6 +292,12 @@ public class SeriesService : ISeriesService await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Translators, PersonRole.Translator, _unitOfWork); } + // Characters + if (!series.Metadata.CharacterLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Characters, PersonRole.Character, _unitOfWork); + } + } series.Metadata.AgeRatingLocked = updateSeriesMetadataDto.SeriesMetadata.AgeRatingLocked; @@ -295,6 +314,7 @@ public class SeriesService : ISeriesService series.Metadata.PencillerLocked = updateSeriesMetadataDto.SeriesMetadata.PencillerLocked; series.Metadata.PublisherLocked = updateSeriesMetadataDto.SeriesMetadata.PublisherLocked; series.Metadata.TranslatorLocked = updateSeriesMetadataDto.SeriesMetadata.TranslatorLocked; + series.Metadata.LocationLocked = updateSeriesMetadataDto.SeriesMetadata.LocationLocked; series.Metadata.CoverArtistLocked = updateSeriesMetadataDto.SeriesMetadata.CoverArtistLocked; series.Metadata.WriterLocked = updateSeriesMetadataDto.SeriesMetadata.WriterLocked; series.Metadata.SummaryLocked = updateSeriesMetadataDto.SeriesMetadata.SummaryLocked; diff --git a/API/Services/Tasks/Metadata/CoverDbService.cs b/API/Services/Tasks/Metadata/CoverDbService.cs index b3c5a6220..ced75565d 100644 --- a/API/Services/Tasks/Metadata/CoverDbService.cs +++ b/API/Services/Tasks/Metadata/CoverDbService.cs @@ -28,7 +28,8 @@ public interface ICoverDbService Task DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat); Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat); Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url); - Task SetPersonCoverImage(Person person, string url, bool fromBase64 = true); + Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true); + Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true); } @@ -460,7 +461,8 @@ public class CoverDbService : ICoverDbService return null; } - public async Task SetPersonCoverImage(Person person, string url, bool fromBase64 = true) + + public async Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true) { if (!string.IsNullOrEmpty(url)) { @@ -490,6 +492,42 @@ public class CoverDbService : ICoverDbService } } + /// + /// Sets the series cover by url + /// + /// + /// + /// + public async Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true) + { + if (!string.IsNullOrEmpty(url)) + { + var filePath = await CreateThumbnail(url, $"{ImageService.GetSeriesFormat(series.Id)}", fromBase64); + + if (!string.IsNullOrEmpty(filePath)) + { + series.CoverImage = filePath; + series.CoverImageLocked = true; + _imageService.UpdateColorScape(series); + _unitOfWork.SeriesRepository.Update(series); + } + } + else + { + series.CoverImage = string.Empty; + series.CoverImageLocked = false; + _imageService.UpdateColorScape(series); + _unitOfWork.SeriesRepository.Update(series); + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series), false); + } + } + private async Task CreateThumbnail(string url, string filename, bool fromBase64 = true) { var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index bcf11b9bd..dd9068a1a 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -194,7 +194,7 @@ public class ProcessSeries : IProcessSeries { // See if any recommendations can link up to the series and pre-fetch external metadata for the series BackgroundJob.Enqueue(() => - _externalMetadataService.GetNewSeriesData(series.Id, series.Library.Type)); + _externalMetadataService.FetchSeriesMetadata(series.Id, series.Library.Type)); await _eventHub.SendMessageAsync(MessageFactory.SeriesAdded, MessageFactory.SeriesAddedEvent(series.Id, series.Name, series.LibraryId), false); @@ -298,7 +298,19 @@ public class ProcessSeries : IProcessSeries } // Set the AgeRating as highest in all the comicInfos - if (!series.Metadata.AgeRatingLocked) series.Metadata.AgeRating = chapters.Max(chapter => chapter.AgeRating); + if (!series.Metadata.AgeRatingLocked) + { + 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) + { + series.Metadata.AgeRating = updatedRating; + } + } DeterminePublicationStatus(series, chapters); @@ -318,7 +330,6 @@ public class ProcessSeries : IProcessSeries await UpdateCollectionTags(series, firstChapter); } - #region PeopleAndTagsAndGenres if (!series.Metadata.WriterLocked) { @@ -414,6 +425,7 @@ public class ProcessSeries : IProcessSeries } #endregion + } private async Task UpdateCollectionTags(Series series, Chapter firstChapter) diff --git a/UI/Web/src/app/_pipes/library-name.pipe.ts b/UI/Web/src/app/_pipes/library-name.pipe.ts new file mode 100644 index 000000000..c34f50166 --- /dev/null +++ b/UI/Web/src/app/_pipes/library-name.pipe.ts @@ -0,0 +1,16 @@ +import {inject, Pipe, PipeTransform} from '@angular/core'; +import {LibraryService} from "../_services/library.service"; +import {Observable} from "rxjs"; + +@Pipe({ + name: 'libraryName', + standalone: true +}) +export class LibraryNamePipe implements PipeTransform { + private readonly libraryService = inject(LibraryService); + + transform(libraryId: number): Observable { + return this.libraryService.getLibraryName(libraryId); + } + +} diff --git a/UI/Web/src/app/_pipes/metadata-setting-filed.pipe.ts b/UI/Web/src/app/_pipes/metadata-setting-filed.pipe.ts new file mode 100644 index 000000000..a9b763c50 --- /dev/null +++ b/UI/Web/src/app/_pipes/metadata-setting-filed.pipe.ts @@ -0,0 +1,35 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {MetadataSettingField} from "../admin/_models/metadata-setting-field"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'metadataSettingFiled', + standalone: true +}) +export class MetadataSettingFiledPipe implements PipeTransform { + + transform(value: MetadataSettingField): string { + switch (value) { + case MetadataSettingField.AgeRating: + return translate('metadata-setting-field-pipe.age-rating'); + case MetadataSettingField.People: + return translate('metadata-setting-field-pipe.people'); + case MetadataSettingField.Covers: + return translate('metadata-setting-field-pipe.covers'); + case MetadataSettingField.Summary: + return translate('metadata-setting-field-pipe.summary'); + case MetadataSettingField.PublicationStatus: + return translate('metadata-setting-field-pipe.publication-status'); + case MetadataSettingField.StartDate: + return translate('metadata-setting-field-pipe.start-date'); + case MetadataSettingField.Genres: + return translate('metadata-setting-field-pipe.genres'); + case MetadataSettingField.Tags: + return translate('metadata-setting-field-pipe.tags'); + case MetadataSettingField.LocalizedName: + return translate('metadata-setting-field-pipe.localized-name'); + + } + } + +} diff --git a/UI/Web/src/app/admin/_models/metadata-setting-field.ts b/UI/Web/src/app/admin/_models/metadata-setting-field.ts new file mode 100644 index 000000000..0448e1af2 --- /dev/null +++ b/UI/Web/src/app/admin/_models/metadata-setting-field.ts @@ -0,0 +1,16 @@ +export enum MetadataSettingField { + Summary = 1, + PublicationStatus = 2, + StartDate = 3, + Genres = 4, + Tags = 5, + LocalizedName = 6, + Covers = 7, + AgeRating = 8, + People = 9 +} + +export const allMetadataSettingField = Object.keys(MetadataSettingField) + .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0) + .map(key => parseInt(key, 10)) as MetadataSettingField[]; + diff --git a/UI/Web/src/app/admin/_models/metadata-settings.ts b/UI/Web/src/app/admin/_models/metadata-settings.ts index 09ce0ad5a..d88e14312 100644 --- a/UI/Web/src/app/admin/_models/metadata-settings.ts +++ b/UI/Web/src/app/admin/_models/metadata-settings.ts @@ -1,5 +1,6 @@ import {AgeRating} from "../../_models/metadata/age-rating"; import {PersonRole} from "../../_models/metadata/person"; +import {MetadataSettingField} from "./metadata-setting-field"; export enum MetadataFieldType { Genre = 0, @@ -22,6 +23,7 @@ export interface MetadataSettings { enableRelationships: boolean; enablePeople: boolean; enableStartDate: boolean; + enableCoverImage: boolean; enableLocalizedName: boolean; enableGenres: boolean; enableTags: boolean; @@ -31,4 +33,5 @@ export interface MetadataSettings { blacklist: Array; whitelist: Array; personRoles: Array; + overrides: Array; } diff --git a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html index 162ef2700..07129cfba 100644 --- a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html +++ b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html @@ -24,7 +24,7 @@ [footerHeight]="50" > - + {{t('series-name-header')}} @@ -34,6 +34,15 @@ + + + {{t('library-name-header')}} + + + {{item.series.libraryId | libraryName | async}} + + + @@ -70,9 +79,13 @@ + {{t('actions-header')}} - + diff --git a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts index 6f1cf7a5a..25a38b960 100644 --- a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts +++ b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts @@ -4,9 +4,7 @@ import {Router} from "@angular/router"; import {TranslocoDirective} from "@jsverse/transloco"; import {ImageComponent} from "../../shared/image/image.component"; import {ImageService} from "../../_services/image.service"; -import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component"; import {Series} from "../../_models/series"; -import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service"; import {ActionService} from "../../_services/action.service"; import {ManageService} from "../../_services/manage.service"; import {ManageMatchSeries} from "../../_models/kavitaplus/manage-match-series"; @@ -19,46 +17,47 @@ import {MatchStateOptionPipe} from "../../_pipes/match-state.pipe"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {debounceTime, distinctUntilChanged, switchMap, tap} from "rxjs"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; -import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapter"; -import {ScrobbleEventType} from "../../_models/scrobbling/scrobble-event"; import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; +import {LibraryNamePipe} from "../../_pipes/library-name.pipe"; +import {AsyncPipe} from "@angular/common"; +import {EVENTS, MessageHubService} from "../../_services/message-hub.service"; +import {ScanSeriesEvent} from "../../_models/events/scan-series-event"; @Component({ selector: 'app-manage-matched-metadata', standalone: true, - imports: [ - TranslocoDirective, - ImageComponent, - CardActionablesComponent, - VirtualScrollerModule, - ReactiveFormsModule, - Select2Module, - MatchStateOptionPipe, - UtcToLocalTimePipe, - DefaultValuePipe, - NgxDatatableModule, - ], + imports: [ + TranslocoDirective, + ImageComponent, + VirtualScrollerModule, + ReactiveFormsModule, + Select2Module, + MatchStateOptionPipe, + UtcToLocalTimePipe, + DefaultValuePipe, + NgxDatatableModule, + LibraryNamePipe, + AsyncPipe, + ], templateUrl: './manage-matched-metadata.component.html', styleUrl: './manage-matched-metadata.component.scss', changeDetection: ChangeDetectionStrategy.OnPush }) export class ManageMatchedMetadataComponent implements OnInit { - protected readonly MatchState = MatchStateOption; + protected readonly ColumnMode = ColumnMode; protected readonly allMatchStates = allMatchStates.filter(m => m !== MatchStateOption.Matched); // Matched will have too many private readonly licenseService = inject(LicenseService); - private readonly actionFactory = inject(ActionFactoryService); private readonly actionService = inject(ActionService); private readonly router = inject(Router); private readonly manageService = inject(ManageService); + private readonly messageHub = inject(MessageHubService); private readonly cdRef = inject(ChangeDetectorRef); protected readonly imageService = inject(ImageService); isLoading: boolean = true; data: Array = []; - actions: Array> = this.actionFactory.getSeriesActions(this.fixMatch.bind(this)) - .filter(item => item.action === Action.Match); filterGroup = new FormGroup({ 'matchState': new FormControl(MatchStateOption.Error, []), }); @@ -71,6 +70,15 @@ export class ManageMatchedMetadataComponent implements OnInit { return; } + this.messageHub.messages$.subscribe(message => { + if (message.event !== EVENTS.ScanSeries) return; + + const evt = message.payload as ScanSeriesEvent; + if (this.data.filter(d => d.series.id === evt.seriesId).length > 0) { + this.loadData(); + } + }); + this.filterGroup.valueChanges.pipe( debounceTime(300), distinctUntilChanged(), @@ -86,7 +94,6 @@ export class ManageMatchedMetadataComponent implements OnInit { ).subscribe(); this.loadData().subscribe(); - }); } @@ -108,21 +115,11 @@ export class ManageMatchedMetadataComponent implements OnInit { })); } - performAction(action: ActionItem, series: Series) { - if (action.callback) { - action.callback(action, series); - } - } - fixMatch(actionItem: ActionItem, series: Series) { + fixMatch(series: Series) { this.actionService.matchSeries(series, result => { if (!result) return; this.loadData().subscribe(); }); } - - protected readonly LooseLeafOrDefaultNumber = LooseLeafOrDefaultNumber; - protected readonly ScrobbleEventType = ScrobbleEventType; - protected readonly SpecialVolumeNumber = SpecialVolumeNumber; - protected readonly ColumnMode = ColumnMode; } 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 a9bc9e742..5277e95bc 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 @@ -29,6 +29,18 @@ } +
+ @if(settingsForm.get('enableLocalizedName'); as formControl) { + + +
+ +
+
+
+ } +
+
@if(settingsForm.get('enablePublicationStatus'); as formControl) { @@ -65,6 +77,18 @@ }
+
+ @if(settingsForm.get('enableCoverImage'); as formControl) { + + +
+ +
+
+
+ } +
+ @if(settingsForm.get('enablePeople'); as formControl) {
@@ -183,13 +207,13 @@
@for(mapping of ageRatingMappings.controls; track mapping; let i = $index) {
-
+
-
+
-
+
+ +
+
+ } +
+ } + } 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 409d4bc5a..bd6426dda 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 @@ -17,6 +17,8 @@ import {MetadataFieldMapping, MetadataFieldType} from "../_models/metadata-setti import {PersonRole} from "../../_models/metadata/person"; import {PersonRolePipe} from "../../_pipes/person-role.pipe"; import {NgClass} from "@angular/common"; +import {allMetadataSettingField} from "../_models/metadata-setting-field"; +import {MetadataSettingFiledPipe} from "../../_pipes/metadata-setting-filed.pipe"; @Component({ @@ -31,6 +33,7 @@ import {NgClass} from "@angular/common"; TagBadgeComponent, AgeRatingPipe, PersonRolePipe, + MetadataSettingFiledPipe, ], templateUrl: './manage-metadata-settings.component.html', styleUrl: './manage-metadata-settings.component.scss', @@ -52,6 +55,7 @@ export class ManageMetadataSettingsComponent implements OnInit { fieldMappings = this.fb.array([]); personRoles: PersonRole[] = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character]; isLoaded = false; + allMetadataSettingFields = allMetadataSettingField; ngOnInit(): void { this.metadataService.getAllAgeRatings().subscribe(ratings => { @@ -66,6 +70,7 @@ export class ManageMetadataSettingsComponent implements OnInit { this.settingService.getMetadataSettings().subscribe(settings => { this.settingsForm.addControl('enabled', new FormControl(settings.enabled, [])); this.settingsForm.addControl('enableSummary', new FormControl(settings.enableSummary, [])); + this.settingsForm.addControl('enableLocalizedName', new FormControl(settings.enableLocalizedName, [])); this.settingsForm.addControl('enablePublicationStatus', new FormControl(settings.enablePublicationStatus, [])); this.settingsForm.addControl('enableRelations', new FormControl(settings.enableRelationships, [])); this.settingsForm.addControl('enableGenres', new FormControl(settings.enableGenres, [])); @@ -73,6 +78,7 @@ export class ManageMetadataSettingsComponent implements OnInit { this.settingsForm.addControl('enableRelationships', new FormControl(settings.enableRelationships, [])); this.settingsForm.addControl('enablePeople', new FormControl(settings.enablePeople, [])); this.settingsForm.addControl('enableStartDate', new FormControl(settings.enableStartDate, [])); + this.settingsForm.addControl('enableCoverImage', new FormControl(settings.enableCoverImage, [])); this.settingsForm.addControl('blacklist', new FormControl((settings.blacklist || '').join(','), [])); this.settingsForm.addControl('whitelist', new FormControl((settings.whitelist || '').join(','), [])); @@ -86,6 +92,15 @@ export class ManageMetadataSettingsComponent implements OnInit { ) )); + this.settingsForm.addControl('overrides', this.fb.group( + Object.fromEntries( + this.allMetadataSettingFields.map((role, index) => [ + `override_${index}`, + this.fb.control((settings.overrides || []).includes(role)), + ]) + ) + )); + if (settings.ageRatingMappings) { Object.entries(settings.ageRatingMappings).forEach(([str, rating]) => { @@ -171,7 +186,10 @@ export class ManageMetadataSettingsComponent implements OnInit { whitelist: (model.whitelist || '').split(',').map((item: string) => item.trim()), personRoles: Object.entries(this.settingsForm.get('personRoles')!.value) .filter(([_, value]) => value) - .map(([key, _]) => this.personRoles[parseInt(key.split('_')[1], 10)]) + .map(([key, _]) => this.personRoles[parseInt(key.split('_')[1], 10)]), + overrides: Object.entries(this.settingsForm.get('overrides')!.value) + .filter(([_, value]) => value) + .map(([key, _]) => this.allMetadataSettingFields[parseInt(key.split('_')[1], 10)]) } } diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 229a7f214..67e74d80e 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -759,14 +759,17 @@ "description": "All applicable Series that can be matched with External Metadata reside here. Kavita will prefetch or refresh series metadata, 50 series per 24 hours daily.", "status-header": "Status", "series-name-header": "Series", + "library-name-header": "Library", "valid-until-header": "Next Refresh", + "actions-header": "Actions", "matched-status-label": "Matched", "unmatched-status-label": "Not Matched", "blacklist-status-label": "Needs Manual Match", "dont-match-status-label": "{{dont-match-label}}", "all-status-label": "All", "dont-match-label": "Don't Match", - "no-data": "{{common.no-data}}" + "no-data": "{{common.no-data}}", + "match-alt": "Match {{seriesName}}" }, "manage-user-tokens": { @@ -785,12 +788,16 @@ "enabled-tooltip": "Allow Kavita to download metadata and write to it's database.", "summary-label": "Summary", "summary-tooltip": "Allow Summary to be written when the field is unlocked.", + "localized-name-label": "Localized Series Name", + "localized-name-tooltip": "Allow Localized Name to be written when the field is unlocked. Kavita will attempt to make the best guess.", "derive-publication-status-label": "Publication Status", "derive-publication-status-tooltip": "Allow Publication Status to be derived from Total Chapter/Volume counts.", "enable-relations-label": "Relationships", "enable-relations-tooltip": "Allow Series Relationships to be added.", "enable-people-label": "People", "enable-people-tooltip": "Allow People (Characters, Writers, etc) to be added. All people include images.", + "enable-cover-image-label": "Cover Image", + "enable-cover-image-tooltip": "Allow Kavita to write the cover image for the Series", "enable-start-date-label": "Start Date", "enable-start-date-tooltip": "Allow Start Date of Series to be written to the Series", "enable-genres-label": "Genres", @@ -812,7 +819,10 @@ "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.", "first-last-name-label": "First Last Naming", "first-last-name-tooltip": "Ensure People's names are written First then Last", - "person-roles-label": "Roles" + "person-roles-label": "Roles", + "overrides-label": "Overrides", + "overrides-description": "Allow Kavita to write over locked fields" + }, "book-line-overlay": { @@ -2613,6 +2623,19 @@ "hours": "Hours" }, + "metadata-setting-field-pipe": { + "covers": "Covers", + "age-rating": "{{metadata-fields.age-rating-title}}", + "people": "{{tabs.people-tab}}", + "summary": "{{filter-field-pipe.summary}}", + "publication-status": "{{edit-series-modal.publication-status-title}}", + "start-date": "{{manage-metadata-settings.enable-start-date-label}}", + "genres": "{{metadata-fields.genres-title}}", + "tags": "{{metadata-fields.tags-title}}", + "localized-name": "{{edit-series-modal.localized-name-label}}" + }, + + "actionable": { "scan-library": "Scan Library", "scan-library-tooltip": "Scan library for changes. Use force scan to force checking every folder", diff --git a/openapi.json b/openapi.json index fd1cf8071..ab22e3a25 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.1", "info": { "title": "Kavita", - "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.4.10", + "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.4.11", "license": { "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" @@ -20002,6 +20002,10 @@ "type": "boolean", "description": "Allow setting the Localized name" }, + "enableCoverImage": { + "type": "boolean", + "description": "Allow setting the cover image" + }, "enableGenres": { "type": "boolean" }, @@ -20048,6 +20052,27 @@ "description": "A list of rules that allow mapping a genre/tag to another genre/tag", "nullable": true }, + "overrides": { + "type": "array", + "items": { + "enum": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "type": "integer", + "description": "Represents which field that can be written to as an override when already locked", + "format": "int32" + }, + "description": "A list of overrides that will enable writing to locked fields", + "nullable": true + }, "blacklist": { "type": "array", "items": { @@ -21728,6 +21753,15 @@ "imageUrl": { "type": "string", "nullable": true + }, + "role": { + "enum": [ + 0, + 1, + 2 + ], + "type": "integer", + "format": "int32" } }, "additionalProperties": false @@ -22407,6 +22441,15 @@ "person": { "$ref": "#/components/schemas/Person" }, + "kavitaPlusConnection": { + "type": "boolean", + "description": "The source of this connection. If not Kavita, this implies Metadata Download linked this and it can be removed between matches" + }, + "orderWeight": { + "type": "integer", + "description": "A weight that allows lower numbers to sort first", + "format": "int32" + }, "role": { "enum": [ 1,