From 8a0a2f0961bbe9ea1a6ad7d190be30135dbe1ba9 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Fri, 3 Feb 2023 04:52:51 -0800 Subject: [PATCH] Scanner Performance Improvements (#1774) * Refactored the Genre code to be faster and used a dictonary to avoid some lookups. May fix the rare foreign constraint issue. * Refactored tag to the same implementation as Genre. Ensure when grabbing tags from ComicInfo, we normalize and throw out duplicates. * Removed an internal "external" field that was planned for Genres and Tags, but now with new plugin architecture, not needed. --- API.Tests/Helpers/GenreHelperTests.cs | 54 +- API.Tests/Helpers/TagHelperTests.cs | 53 +- API.Tests/Services/SeriesServiceTests.cs | 10 +- API/Data/DbFactory.cs | 6 +- ..._RemoveExternalFromTagAndGenre.Designer.cs | 1748 +++++++++++++++++ ...203112022_RemoveExternalFromTagAndGenre.cs | 77 + .../Migrations/DataContextModelSnapshot.cs | 10 +- API/Data/Repositories/GenreRepository.cs | 2 +- API/Data/Repositories/TagRepository.cs | 2 +- API/Entities/Genre.cs | 3 +- API/Entities/Person.cs | 6 - API/Entities/Tag.cs | 3 +- API/Helpers/GenreHelper.cs | 10 +- API/Helpers/TagHelper.cs | 13 +- API/Services/SeriesService.cs | 8 +- API/Services/Tasks/Scanner/Parser/Parser.cs | 2 +- API/Services/Tasks/Scanner/ProcessSeries.cs | 62 +- openapi.json | 8 +- 18 files changed, 1925 insertions(+), 152 deletions(-) create mode 100644 API/Data/Migrations/20230203112022_RemoveExternalFromTagAndGenre.Designer.cs create mode 100644 API/Data/Migrations/20230203112022_RemoveExternalFromTagAndGenre.cs diff --git a/API.Tests/Helpers/GenreHelperTests.cs b/API.Tests/Helpers/GenreHelperTests.cs index 94602ff01..1cda535fd 100644 --- a/API.Tests/Helpers/GenreHelperTests.cs +++ b/API.Tests/Helpers/GenreHelperTests.cs @@ -13,13 +13,13 @@ public class GenreHelperTests { var allGenres = new List { - DbFactory.Genre("Action", false), - DbFactory.Genre("action", false), - DbFactory.Genre("Sci-fi", false), + DbFactory.Genre("Action"), + DbFactory.Genre("action"), + DbFactory.Genre("Sci-fi"), }; var genreAdded = new List(); - GenreHelper.UpdateGenre(allGenres, new[] {"Action", "Adventure"}, false, genre => + GenreHelper.UpdateGenre(allGenres, new[] {"Action", "Adventure"}, genre => { genreAdded.Add(genre); }); @@ -33,19 +33,20 @@ public class GenreHelperTests { var allGenres = new List { - DbFactory.Genre("Action", false), - DbFactory.Genre("action", false), - DbFactory.Genre("Sci-fi", false), + DbFactory.Genre("Action"), + DbFactory.Genre("action"), + DbFactory.Genre("Sci-fi"), }; var genreAdded = new List(); - GenreHelper.UpdateGenre(allGenres, new[] {"Action", "Scifi"}, false, genre => + GenreHelper.UpdateGenre(allGenres, new[] {"Action", "Scifi"}, genre => { genreAdded.Add(genre); }); Assert.Equal(3, allGenres.Count); + Assert.Equal(2, genreAdded.Count); } [Fact] @@ -53,49 +54,34 @@ public class GenreHelperTests { var existingGenres = new List { - DbFactory.Genre("Action", false), - DbFactory.Genre("action", false), - DbFactory.Genre("Sci-fi", false), + DbFactory.Genre("Action"), + DbFactory.Genre("action"), + DbFactory.Genre("Sci-fi"), }; - GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("Action", false)); + GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("Action")); Assert.Equal(3, existingGenres.Count); - GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("action", false)); + GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("action")); Assert.Equal(3, existingGenres.Count); - GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("Shonen", false)); + GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("Shonen")); Assert.Equal(4, existingGenres.Count); } - [Fact] - public void AddGenre_ShouldNotAddSameNameAndExternal() - { - var existingGenres = new List - { - DbFactory.Genre("Action", false), - DbFactory.Genre("action", false), - DbFactory.Genre("Sci-fi", false), - }; - - - GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("Action", true)); - Assert.Equal(3, existingGenres.Count); - } - [Fact] public void KeepOnlySamePeopleBetweenLists() { var existingGenres = new List { - DbFactory.Genre("Action", false), - DbFactory.Genre("Sci-fi", false), + DbFactory.Genre("Action"), + DbFactory.Genre("Sci-fi"), }; var peopleFromChapters = new List { - DbFactory.Genre("Action", false), + DbFactory.Genre("Action"), }; var genreRemoved = new List(); @@ -113,8 +99,8 @@ public class GenreHelperTests { var existingGenres = new List { - DbFactory.Genre("Action", false), - DbFactory.Genre("Sci-fi", false), + DbFactory.Genre("Action"), + DbFactory.Genre("Sci-fi"), }; var peopleFromChapters = new List(); diff --git a/API.Tests/Helpers/TagHelperTests.cs b/API.Tests/Helpers/TagHelperTests.cs index 80cebc03b..bc8c1be17 100644 --- a/API.Tests/Helpers/TagHelperTests.cs +++ b/API.Tests/Helpers/TagHelperTests.cs @@ -13,13 +13,13 @@ public class TagHelperTests { var allTags = new List { - DbFactory.Tag("Action", false), - DbFactory.Tag("action", false), - DbFactory.Tag("Sci-fi", false), + DbFactory.Tag("Action"), + DbFactory.Tag("action"), + DbFactory.Tag("Sci-fi"), }; var tagAdded = new List(); - TagHelper.UpdateTag(allTags, new[] {"Action", "Adventure"}, false, (tag, added) => + TagHelper.UpdateTag(allTags, new[] {"Action", "Adventure"}, (tag, added) => { if (added) { @@ -37,14 +37,14 @@ public class TagHelperTests { var allTags = new List { - DbFactory.Tag("Action", false), - DbFactory.Tag("action", false), - DbFactory.Tag("Sci-fi", false), + DbFactory.Tag("Action"), + DbFactory.Tag("action"), + DbFactory.Tag("Sci-fi"), }; var tagAdded = new List(); - TagHelper.UpdateTag(allTags, new[] {"Action", "Scifi"}, false, (tag, added) => + TagHelper.UpdateTag(allTags, new[] {"Action", "Scifi"}, (tag, added) => { if (added) { @@ -62,49 +62,34 @@ public class TagHelperTests { var existingTags = new List { - DbFactory.Tag("Action", false), - DbFactory.Tag("action", false), - DbFactory.Tag("Sci-fi", false), + DbFactory.Tag("Action"), + DbFactory.Tag("action"), + DbFactory.Tag("Sci-fi"), }; - TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("Action", false)); + TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("Action")); Assert.Equal(3, existingTags.Count); - TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("action", false)); + TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("action")); Assert.Equal(3, existingTags.Count); - TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("Shonen", false)); + TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("Shonen")); Assert.Equal(4, existingTags.Count); } - [Fact] - public void AddTag_ShouldNotAddSameNameAndExternal() - { - var existingTags = new List - { - DbFactory.Tag("Action", false), - DbFactory.Tag("action", false), - DbFactory.Tag("Sci-fi", false), - }; - - - TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("Action", true)); - Assert.Equal(3, existingTags.Count); - } - [Fact] public void KeepOnlySamePeopleBetweenLists() { var existingTags = new List { - DbFactory.Tag("Action", false), - DbFactory.Tag("Sci-fi", false), + DbFactory.Tag("Action"), + DbFactory.Tag("Sci-fi"), }; var peopleFromChapters = new List { - DbFactory.Tag("Action", false), + DbFactory.Tag("Action"), }; var tagRemoved = new List(); @@ -122,8 +107,8 @@ public class TagHelperTests { var existingTags = new List { - DbFactory.Tag("Action", false), - DbFactory.Tag("Sci-fi", false), + DbFactory.Tag("Action"), + DbFactory.Tag("Sci-fi"), }; var peopleFromChapters = new List(); diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index 4d9bbbfdd..e0b24c812 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -762,7 +762,7 @@ public class SeriesServiceTests : AbstractDbTest }, Metadata = DbFactory.SeriesMetadata(new List()) }; - var g = DbFactory.Genre("Existing Genre", false); + var g = DbFactory.Genre("Existing Genre"); s.Metadata.Genres = new List() {g}; _context.Series.Add(s); @@ -918,7 +918,7 @@ public class SeriesServiceTests : AbstractDbTest }, Metadata = DbFactory.SeriesMetadata(new List()) }; - var g = DbFactory.Genre("Existing Genre", false); + var g = DbFactory.Genre("Existing Genre"); s.Metadata.Genres = new List() {g}; s.Metadata.GenresLocked = true; _context.Series.Add(s); @@ -1555,5 +1555,11 @@ public class SeriesServiceTests : AbstractDbTest Assert.Null(await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1)); } + #endregion + + #region UpdateRelatedList + + + #endregion } diff --git a/API/Data/DbFactory.cs b/API/Data/DbFactory.cs index 33b6678fd..fc952b051 100644 --- a/API/Data/DbFactory.cs +++ b/API/Data/DbFactory.cs @@ -121,23 +121,21 @@ public static class DbFactory }; } - public static Genre Genre(string name, bool external) + public static Genre Genre(string name) { return new Genre() { Title = name.Trim().SentenceCase(), NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(name), - ExternalTag = external }; } - public static Tag Tag(string name, bool external) + public static Tag Tag(string name) { return new Tag() { Title = name.Trim().SentenceCase(), NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(name), - ExternalTag = external }; } diff --git a/API/Data/Migrations/20230203112022_RemoveExternalFromTagAndGenre.Designer.cs b/API/Data/Migrations/20230203112022_RemoveExternalFromTagAndGenre.Designer.cs new file mode 100644 index 000000000..008e9690f --- /dev/null +++ b/API/Data/Migrations/20230203112022_RemoveExternalFromTagAndGenre.Designer.cs @@ -0,0 +1,1748 @@ +// +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("20230203112022_RemoveExternalFromTagAndGenre")] + partial class RemoveExternalFromTagAndGenre + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.10"); + + 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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .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("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .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.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + 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("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + 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("LastModified") + .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("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.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.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .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.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.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInDashboard") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInRecommended") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + 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("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + 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("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .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("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + 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.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + 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("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .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.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + 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("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + 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("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + 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("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("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + 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.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.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", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .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.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.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.Series", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany("WantToRead") + .HasForeignKey("AppUserId"); + + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("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("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .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("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("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .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("Devices"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + 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/20230203112022_RemoveExternalFromTagAndGenre.cs b/API/Data/Migrations/20230203112022_RemoveExternalFromTagAndGenre.cs new file mode 100644 index 000000000..44216e4db --- /dev/null +++ b/API/Data/Migrations/20230203112022_RemoveExternalFromTagAndGenre.cs @@ -0,0 +1,77 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class RemoveExternalFromTagAndGenre : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Tag_NormalizedTitle_ExternalTag", + table: "Tag"); + + migrationBuilder.DropIndex( + name: "IX_Genre_NormalizedTitle_ExternalTag", + table: "Genre"); + + migrationBuilder.DropColumn( + name: "ExternalTag", + table: "Tag"); + + migrationBuilder.DropColumn( + name: "ExternalTag", + table: "Genre"); + + migrationBuilder.CreateIndex( + name: "IX_Tag_NormalizedTitle", + table: "Tag", + column: "NormalizedTitle", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Genre_NormalizedTitle", + table: "Genre", + column: "NormalizedTitle", + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Tag_NormalizedTitle", + table: "Tag"); + + migrationBuilder.DropIndex( + name: "IX_Genre_NormalizedTitle", + table: "Genre"); + + migrationBuilder.AddColumn( + name: "ExternalTag", + table: "Tag", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ExternalTag", + table: "Genre", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateIndex( + name: "IX_Tag_NormalizedTitle_ExternalTag", + table: "Tag", + columns: new[] { "NormalizedTitle", "ExternalTag" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Genre_NormalizedTitle_ExternalTag", + table: "Genre", + columns: new[] { "NormalizedTitle", "ExternalTag" }, + unique: true); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index d5bda4ef4..13a553b30 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -528,9 +528,6 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("ExternalTag") - .HasColumnType("INTEGER"); - b.Property("NormalizedTitle") .HasColumnType("TEXT"); @@ -539,7 +536,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.HasIndex("NormalizedTitle", "ExternalTag") + b.HasIndex("NormalizedTitle") .IsUnique(); b.ToTable("Genre"); @@ -1036,9 +1033,6 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("ExternalTag") - .HasColumnType("INTEGER"); - b.Property("NormalizedTitle") .HasColumnType("TEXT"); @@ -1047,7 +1041,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.HasIndex("NormalizedTitle", "ExternalTag") + b.HasIndex("NormalizedTitle") .IsUnique(); b.ToTable("Tag"); diff --git a/API/Data/Repositories/GenreRepository.cs b/API/Data/Repositories/GenreRepository.cs index 464914b70..2533a40cf 100644 --- a/API/Data/Repositories/GenreRepository.cs +++ b/API/Data/Repositories/GenreRepository.cs @@ -56,7 +56,7 @@ public class GenreRepository : IGenreRepository var genresWithNoConnections = await _context.Genre .Include(p => p.SeriesMetadatas) .Include(p => p.Chapters) - .Where(p => p.SeriesMetadatas.Count == 0 && p.Chapters.Count == 0 && p.ExternalTag == removeExternal) + .Where(p => p.SeriesMetadatas.Count == 0 && p.Chapters.Count == 0) .AsSplitQuery() .ToListAsync(); diff --git a/API/Data/Repositories/TagRepository.cs b/API/Data/Repositories/TagRepository.cs index ac88d04a7..07f895193 100644 --- a/API/Data/Repositories/TagRepository.cs +++ b/API/Data/Repositories/TagRepository.cs @@ -46,7 +46,7 @@ public class TagRepository : ITagRepository var tagsWithNoConnections = await _context.Tag .Include(p => p.SeriesMetadatas) .Include(p => p.Chapters) - .Where(p => p.SeriesMetadatas.Count == 0 && p.Chapters.Count == 0 && p.ExternalTag == removeExternal) + .Where(p => p.SeriesMetadatas.Count == 0 && p.Chapters.Count == 0) .AsSplitQuery() .ToListAsync(); diff --git a/API/Entities/Genre.cs b/API/Entities/Genre.cs index ec9cdde0e..393a67860 100644 --- a/API/Entities/Genre.cs +++ b/API/Entities/Genre.cs @@ -4,13 +4,12 @@ using Microsoft.EntityFrameworkCore; namespace API.Entities; -[Index(nameof(NormalizedTitle), nameof(ExternalTag), IsUnique = true)] +[Index(nameof(NormalizedTitle), IsUnique = true)] public class Genre { public int Id { get; set; } public string Title { get; set; } public string NormalizedTitle { get; set; } - public bool ExternalTag { get; set; } public ICollection SeriesMetadatas { get; set; } public ICollection Chapters { get; set; } diff --git a/API/Entities/Person.cs b/API/Entities/Person.cs index 4029b6af9..a7b8ea1c6 100644 --- a/API/Entities/Person.cs +++ b/API/Entities/Person.cs @@ -4,18 +4,12 @@ using API.Entities.Metadata; namespace API.Entities; -public enum ProviderSource -{ - Local = 1, - External = 2 -} public class Person { public int Id { get; set; } public string Name { get; set; } public string NormalizedName { get; set; } public PersonRole Role { get; set; } - //public ProviderSource Source { get; set; } // Relationships public ICollection SeriesMetadatas { get; set; } diff --git a/API/Entities/Tag.cs b/API/Entities/Tag.cs index 5d1631760..1676b2fd2 100644 --- a/API/Entities/Tag.cs +++ b/API/Entities/Tag.cs @@ -4,13 +4,12 @@ using Microsoft.EntityFrameworkCore; namespace API.Entities; -[Index(nameof(NormalizedTitle), nameof(ExternalTag), IsUnique = true)] +[Index(nameof(NormalizedTitle), IsUnique = true)] public class Tag { public int Id { get; set; } public string Title { get; set; } public string NormalizedTitle { get; set; } - public bool ExternalTag { get; set; } public ICollection SeriesMetadatas { get; set; } public ICollection Chapters { get; set; } diff --git a/API/Helpers/GenreHelper.cs b/API/Helpers/GenreHelper.cs index 631baf85c..f6d6e85e9 100644 --- a/API/Helpers/GenreHelper.cs +++ b/API/Helpers/GenreHelper.cs @@ -14,20 +14,18 @@ public static class GenreHelper /// /// /// - /// /// - public static void UpdateGenre(ICollection allGenres, IEnumerable names, bool isExternal, Action action) + public static void UpdateGenre(ICollection allGenres, IEnumerable names, Action action) { foreach (var name in names) { if (string.IsNullOrEmpty(name.Trim())) continue; var normalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize(name); - var genre = allGenres.FirstOrDefault(p => - p.NormalizedTitle.Equals(normalizedName) && p.ExternalTag == isExternal); + var genre = allGenres.FirstOrDefault(p => p.NormalizedTitle.Equals(normalizedName)); if (genre == null) { - genre = DbFactory.Genre(name, false); + genre = DbFactory.Genre(name); allGenres.Add(genre); } @@ -41,7 +39,7 @@ public static class GenreHelper var existing = existingGenres.ToList(); foreach (var genre in existing) { - var existingPerson = removeAllExcept.FirstOrDefault(g => g.ExternalTag == genre.ExternalTag && genre.NormalizedTitle.Equals(g.NormalizedTitle)); + var existingPerson = removeAllExcept.FirstOrDefault(g => genre.NormalizedTitle.Equals(g.NormalizedTitle)); if (existingPerson != null) continue; existingGenres.Remove(genre); action?.Invoke(genre); diff --git a/API/Helpers/TagHelper.cs b/API/Helpers/TagHelper.cs index f7b1abfd4..4844f9587 100644 --- a/API/Helpers/TagHelper.cs +++ b/API/Helpers/TagHelper.cs @@ -14,9 +14,8 @@ public static class TagHelper /// /// /// - /// /// Callback for every item. Will give said item back and a bool if item was added - public static void UpdateTag(ICollection allTags, IEnumerable names, bool isExternal, Action action) + public static void UpdateTag(ICollection allTags, IEnumerable names, Action action) { foreach (var name in names) { @@ -26,11 +25,11 @@ public static class TagHelper var normalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize(name); var genre = allTags.FirstOrDefault(p => - p.NormalizedTitle.Equals(normalizedName) && p.ExternalTag == isExternal); + p.NormalizedTitle.Equals(normalizedName)); if (genre == null) { added = true; - genre = DbFactory.Tag(name, false); + genre = DbFactory.Tag(name); allTags.Add(genre); } @@ -43,7 +42,7 @@ public static class TagHelper var existing = existingTags.ToList(); foreach (var genre in existing) { - var existingPerson = removeAllExcept.FirstOrDefault(g => g.ExternalTag == genre.ExternalTag && genre.NormalizedTitle.Equals(g.NormalizedTitle)); + var existingPerson = removeAllExcept.FirstOrDefault(g => genre.NormalizedTitle.Equals(g.NormalizedTitle)); if (existingPerson != null) continue; existingTags.Remove(genre); action?.Invoke(genre); @@ -84,12 +83,12 @@ public static class TagHelper /// Tags from metadata /// Remove external tags? /// Callback which will be executed for each tag removed - public static void RemoveTags(ICollection existingTags, IEnumerable tags, bool isExternal, Action action = null) + public static void RemoveTags(ICollection existingTags, IEnumerable tags, Action action = null) { var normalizedTags = tags.Select(Services.Tasks.Scanner.Parser.Parser.Normalize).ToList(); foreach (var person in normalizedTags) { - var existingTag = existingTags.FirstOrDefault(p => p.ExternalTag == isExternal && person.Equals(p.NormalizedTitle)); + var existingTag = existingTags.FirstOrDefault(p => person.Equals(p.NormalizedTitle)); if (existingTag == null) continue; existingTags.Remove(existingTag); diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index b2e190e04..1dffcd1ac 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -115,7 +115,7 @@ public class SeriesService : ISeriesService } series.Metadata.CollectionTags ??= new List(); - UpdateRelatedList(updateSeriesMetadataDto.CollectionTags, series, allCollectionTags, (tag) => + UpdateCollectionsList(updateSeriesMetadataDto.CollectionTags, series, allCollectionTags, (tag) => { series.Metadata.CollectionTags.Add(tag); }); @@ -210,7 +210,7 @@ public class SeriesService : ISeriesService } - private static void UpdateRelatedList(ICollection tags, Series series, IReadOnlyCollection allTags, + public static void UpdateCollectionsList(ICollection tags, Series series, IReadOnlyCollection allTags, Action handleAdd) { // TODO: Move UpdateRelatedList to a helper so we can easily test @@ -278,7 +278,7 @@ public class SeriesService : ISeriesService else { // Add new tag - handleAdd(DbFactory.Genre(tagTitle, false)); + handleAdd(DbFactory.Genre(tagTitle)); isModified = true; } } @@ -320,7 +320,7 @@ public class SeriesService : ISeriesService else { // Add new tag - handleAdd(DbFactory.Tag(tagTitle, false)); + handleAdd(DbFactory.Tag(tagTitle)); isModified = true; } } diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index a81ebfa29..39952c1fa 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -945,7 +945,7 @@ public static class Parser public static string Normalize(string name) { - return NormalizeRegex.Replace(name, string.Empty).ToLower(); + return NormalizeRegex.Replace(name, string.Empty).Trim().ToLower(); } /// diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index cfc1ed224..527cc89ec 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -48,9 +48,9 @@ public class ProcessSeries : IProcessSeries private readonly IWordCountAnalyzerService _wordCountAnalyzerService; private readonly ICollectionTagService _collectionTagService; - private IList _genres; + private Dictionary _genres; private IList _people; - private IList _tags; + private Dictionary _tags; private Dictionary _collectionTags; public ProcessSeries(IUnitOfWork unitOfWork, ILogger logger, IEventHub eventHub, @@ -75,9 +75,9 @@ public class ProcessSeries : IProcessSeries /// public async Task Prime() { - _genres = await _unitOfWork.GenreRepository.GetAllGenresAsync(); + _genres = (await _unitOfWork.GenreRepository.GetAllGenresAsync()).ToDictionary(t => t.NormalizedTitle); _people = await _unitOfWork.PersonRepository.GetAllPeople(); - _tags = await _unitOfWork.TagRepository.GetAllTagsAsync(); + _tags = (await _unitOfWork.TagRepository.GetAllTagsAsync()).ToDictionary(t => t.NormalizedTitle); _collectionTags = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync(CollectionTagIncludes.SeriesMetadata)) .ToDictionary(t => t.NormalizedTitle); @@ -673,14 +673,14 @@ public class ProcessSeries : IProcessSeries PersonHelper.AddPersonIfNotExists(chapter.People, person); } - void AddGenre(Genre genre) + void AddGenre(Genre genre, bool newTag) { - GenreHelper.AddGenreIfNotExists(chapter.Genres, genre); + chapter.Genres.Add(genre); } void AddTag(Tag tag, bool added) { - TagHelper.AddTagIfNotExists(chapter.Tags, tag); + chapter.Tags.Add(tag); } @@ -745,14 +745,13 @@ public class ProcessSeries : IProcessSeries AddPerson); var genres = GetTagValues(comicInfo.Genre); - GenreHelper.KeepOnlySameGenreBetweenLists(chapter.Genres, genres.Select(g => DbFactory.Genre(g, false)).ToList()); - UpdateGenre(genres, false, - AddGenre); + GenreHelper.KeepOnlySameGenreBetweenLists(chapter.Genres, + genres.Select(DbFactory.Genre).ToList()); + UpdateGenre(genres, AddGenre); var tags = GetTagValues(comicInfo.Tags); - TagHelper.KeepOnlySameTagBetweenLists(chapter.Tags, tags.Select(t => DbFactory.Tag(t, false)).ToList()); - UpdateTag(tags, false, - AddTag); + TagHelper.KeepOnlySameTagBetweenLists(chapter.Tags, tags.Select(DbFactory.Tag).ToList()); + UpdateTag(tags, AddTag); } private static IList GetTagValues(string comicInfoTagSeparatedByComma) @@ -760,7 +759,7 @@ public class ProcessSeries : IProcessSeries if (!string.IsNullOrEmpty(comicInfoTagSeparatedByComma)) { - return comicInfoTagSeparatedByComma.Split(",").Select(s => s.Trim()).ToList(); + return comicInfoTagSeparatedByComma.Split(",").Select(s => s.Trim()).DistinctBy(s => s.Normalize()).ToList(); } return ImmutableList.Empty; } @@ -801,27 +800,27 @@ public class ProcessSeries : IProcessSeries /// /// /// - /// - /// - private void UpdateGenre(IEnumerable names, bool isExternal, Action action) + /// Executes for each tag + private void UpdateGenre(IEnumerable names, Action action) { foreach (var name in names) { - if (string.IsNullOrEmpty(name.Trim())) continue; - var normalizedName = Parser.Parser.Normalize(name); - var genre = _genres.FirstOrDefault(p => - p.NormalizedTitle.Equals(normalizedName) && p.ExternalTag == isExternal); - if (genre == null) + if (string.IsNullOrEmpty(normalizedName)) continue; + + _genres.TryGetValue(normalizedName, out var genre); + var newTag = genre == null; + if (newTag) { - genre = DbFactory.Genre(name, false); + genre = DbFactory.Genre(name); lock (_genres) { - _genres.Add(genre); + _genres.Add(normalizedName, genre); + _unitOfWork.GenreRepository.Attach(genre); } } - action(genre); + action(genre, newTag); } } @@ -829,26 +828,23 @@ public class ProcessSeries : IProcessSeries /// /// /// - /// /// Callback for every item. Will give said item back and a bool if item was added - private void UpdateTag(IEnumerable names, bool isExternal, Action action) + private void UpdateTag(IEnumerable names, Action action) { foreach (var name in names) { if (string.IsNullOrEmpty(name.Trim())) continue; - var added = false; var normalizedName = Parser.Parser.Normalize(name); + _tags.TryGetValue(normalizedName, out var tag); - var tag = _tags.FirstOrDefault(p => - p.NormalizedTitle.Equals(normalizedName) && p.ExternalTag == isExternal); + var added = tag == null; if (tag == null) { - added = true; - tag = DbFactory.Tag(name, false); + tag = DbFactory.Tag(name); lock (_tags) { - _tags.Add(tag); + _tags.Add(normalizedName, tag); } } diff --git a/openapi.json b/openapi.json index 4a306bfab..1eb30f82b 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.6.1.33" + "version": "0.6.1.34" }, "servers": [ { @@ -10935,9 +10935,6 @@ "type": "string", "nullable": true }, - "externalTag": { - "type": "boolean" - }, "seriesMetadatas": { "type": "array", "items": { @@ -13562,9 +13559,6 @@ "type": "string", "nullable": true }, - "externalTag": { - "type": "boolean" - }, "seriesMetadatas": { "type": "array", "items": {