From 0be0e294aa2c7845bcebaa8a12700e5fc6460a40 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sat, 8 Jan 2022 06:41:47 -0800 Subject: [PATCH] Metadata Optimizations (#910) * Added a tooltip to inform user that format and collection filter selections do not only show for the selected library. * Refactored a lot of code around when we update chapter cover images. Applied an optimization for when we re-calculate volume/series covers, such that it only occurs when the first chapter's image updates. * Updated code to ensure only lastmodified gets refreshed in metadata since it always follows a scan * Optimized how metadata is populated on the series. Instead of re-reading the comicInfos, instead I read the data from the underlying chapter entities. This reduces N additional reads AND enables the ability in the future to show/edit chapter level metadata. * Spelling mistake * Fixed a concurency issue by not selecting Genres from DB. Added a test for long paths. * Fixed a bug in filter where collection tag wasn't populating on load * Cleaned up the logic for changelog to better compare against the installed verison. For nightly users, show the last stable as installed. * Removed some demo code * SplitQuery to allow loading tags much faster for series metadata load. --- API.Tests/Helpers/CacheHelperTests.cs | 16 + API.Tests/Parser/ParserTest.cs | 2 +- API.Tests/Services/DirectoryServiceTests.cs | 33 + API/Comparators/NaturalSortComparer.cs | 2 +- API/DTOs/ChapterDto.cs | 8 + API/Data/DbFactory.cs | 3 +- API/Data/Metadata/ComicInfo.cs | 1 + ...22_ChapterMetadataOptimization.Designer.cs | 1339 +++++++++++++++++ ...20107232822_ChapterMetadataOptimization.cs | 108 ++ .../Migrations/DataContextModelSnapshot.cs | 53 +- API/Data/Repositories/SeriesRepository.cs | 13 +- API/Entities/AppUserBookmark.cs | 3 +- API/Entities/Chapter.cs | 12 + API/Entities/MangaFile.cs | 4 +- API/Helpers/CacheHelper.cs | 2 +- API/Services/MetadataService.cs | 119 +- API/Services/Tasks/ScannerService.cs | 1 + .../admin/changelog/changelog.component.html | 6 +- .../admin/changelog/changelog.component.ts | 19 +- .../card-detail-layout.component.html | 7 +- .../card-detail-layout.component.ts | 10 +- 21 files changed, 1671 insertions(+), 90 deletions(-) create mode 100644 API/Data/Migrations/20220107232822_ChapterMetadataOptimization.Designer.cs create mode 100644 API/Data/Migrations/20220107232822_ChapterMetadataOptimization.cs diff --git a/API.Tests/Helpers/CacheHelperTests.cs b/API.Tests/Helpers/CacheHelperTests.cs index 6d286f541..ba9a8ad45 100644 --- a/API.Tests/Helpers/CacheHelperTests.cs +++ b/API.Tests/Helpers/CacheHelperTests.cs @@ -1,10 +1,13 @@ using System; using System.Collections.Generic; using System.IO; +using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; using API.Entities; using API.Helpers; using API.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; using Xunit; namespace API.Tests.Helpers; @@ -73,6 +76,19 @@ public class CacheHelperTests false, false)); } + [Fact] + public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetNotLocked_2() + { + // Represents first run + var file = new MangaFile() + { + FilePath = TestCoverArchive, + LastModified = DateTime.Now + }; + Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now, + false, false)); + } + [Fact] public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetLocked() { diff --git a/API.Tests/Parser/ParserTest.cs b/API.Tests/Parser/ParserTest.cs index d1aee7760..ba98d4874 100644 --- a/API.Tests/Parser/ParserTest.cs +++ b/API.Tests/Parser/ParserTest.cs @@ -79,7 +79,7 @@ namespace API.Tests.Parser [InlineData("src: local(\"/fonts/OpenSans-Regular-webfont.woff2\")", new [] {"src: local(\"", "/fonts/OpenSans-Regular-webfont.woff2", "\")"})] public void FontCssCorrectlySeparates(string input, string[] expected) { - Assert.Equal(expected, FontSrcUrlRegex.Match(input).Groups.Values.Select(g => g.Value).Where((s, i) => i > 0).ToArray()); + Assert.Equal(expected, FontSrcUrlRegex.Match(input).Groups.Values.Select(g => g.Value).Where((_, i) => i > 0).ToArray()); } diff --git a/API.Tests/Services/DirectoryServiceTests.cs b/API.Tests/Services/DirectoryServiceTests.cs index 0915c53fb..ba6002141 100644 --- a/API.Tests/Services/DirectoryServiceTests.cs +++ b/API.Tests/Services/DirectoryServiceTests.cs @@ -52,6 +52,39 @@ namespace API.Tests.Services Assert.Equal(28, files.Count); } + [Fact] + public void TraverseTreeParallelForEach_LongDirectory_ShouldBe1() + { + var fileSystem = new MockFileSystem(); + // Create a super long path + var testDirectory = "/manga/"; + for (var i = 0; i < 200; i++) + { + testDirectory = fileSystem.FileSystem.Path.Join(testDirectory, "supercalifragilisticexpialidocious"); + } + + + fileSystem.AddFile(fileSystem.FileSystem.Path.Join(testDirectory, "file_29.jpg"), new MockFileData("")); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var files = new List(); + try + { + var fileCount = ds.TraverseTreeParallelForEach("/manga/", s => files.Add(s), + API.Parser.Parser.ImageFileExtensions, _logger); + Assert.Equal(1, fileCount); + } + catch (Exception ex) + { + Assert.False(true); + } + + + Assert.Equal(1, files.Count); + } + + + [Fact] public void TraverseTreeParallelForEach_DontCountExcludedDirectories_ShouldBe28() { diff --git a/API/Comparators/NaturalSortComparer.cs b/API/Comparators/NaturalSortComparer.cs index 650edb7ce..b65d06e95 100644 --- a/API/Comparators/NaturalSortComparer.cs +++ b/API/Comparators/NaturalSortComparer.cs @@ -23,7 +23,7 @@ namespace API.Comparators _isAscending = inAscendingOrder; } - int IComparer.Compare(string x, string y) + int IComparer.Compare(string? x, string? y) { if (x == y) return 0; diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index 97bdd4310..7adda1771 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -62,6 +62,14 @@ namespace API.DTOs /// /// Metadata field public string TitleName { get; set; } + /// + /// Summary for the Chapter/Issue + /// + public string Summary { get; set; } + /// + /// Language for the Chapter/Issue + /// + public string Language { get; set; } public ICollection Writers { get; set; } = new List(); public ICollection Penciller { get; set; } = new List(); public ICollection Inker { get; set; } = new List(); diff --git a/API/Data/DbFactory.cs b/API/Data/DbFactory.cs index e32be0450..d5b3434f7 100644 --- a/API/Data/DbFactory.cs +++ b/API/Data/DbFactory.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using API.Data.Metadata; using API.Entities; using API.Entities.Enums; @@ -118,7 +119,7 @@ namespace API.Data FilePath = filePath, Format = format, Pages = pages, - LastModified = DateTime.Now //File.GetLastWriteTime(filePath) + LastModified = File.GetLastWriteTime(filePath) // NOTE: Changed this from DateTime.Now }; } diff --git a/API/Data/Metadata/ComicInfo.cs b/API/Data/Metadata/ComicInfo.cs index 4c3e13107..21de639ae 100644 --- a/API/Data/Metadata/ComicInfo.cs +++ b/API/Data/Metadata/ComicInfo.cs @@ -85,5 +85,6 @@ namespace API.Data.Metadata .SingleOrDefault(t => t.ToDescription().ToUpperInvariant().Equals(value.ToUpperInvariant()), Entities.Enums.AgeRating.Unknown); } + } } diff --git a/API/Data/Migrations/20220107232822_ChapterMetadataOptimization.Designer.cs b/API/Data/Migrations/20220107232822_ChapterMetadataOptimization.Designer.cs new file mode 100644 index 000000000..9df425dbd --- /dev/null +++ b/API/Data/Migrations/20220107232822_ChapterMetadataOptimization.Designer.cs @@ -0,0 +1,1339 @@ +// +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("20220107232822_ChapterMetadataOptimization")] + partial class ChapterMetadataOptimization + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); + + 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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .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("FileName") + .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("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + 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("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("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("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .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.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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .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("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + 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("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + 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("Language") + .HasColumnType("TEXT"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + 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("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .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("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + 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.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .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.Navigation("AppUser"); + }); + + 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.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.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.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("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + 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("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220107232822_ChapterMetadataOptimization.cs b/API/Data/Migrations/20220107232822_ChapterMetadataOptimization.cs new file mode 100644 index 000000000..28e874f03 --- /dev/null +++ b/API/Data/Migrations/20220107232822_ChapterMetadataOptimization.cs @@ -0,0 +1,108 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class ChapterMetadataOptimization : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Chapter_Genre_GenreId", + table: "Chapter"); + + migrationBuilder.DropIndex( + name: "IX_Chapter_GenreId", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "GenreId", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "FullscreenMode", + table: "AppUserPreferences"); + + migrationBuilder.AddColumn( + name: "Language", + table: "Chapter", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Summary", + table: "Chapter", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "ChapterGenre", + columns: table => new + { + ChaptersId = table.Column(type: "INTEGER", nullable: false), + GenresId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChapterGenre", x => new { x.ChaptersId, x.GenresId }); + table.ForeignKey( + name: "FK_ChapterGenre_Chapter_ChaptersId", + column: x => x.ChaptersId, + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ChapterGenre_Genre_GenresId", + column: x => x.GenresId, + principalTable: "Genre", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChapterGenre_GenresId", + table: "ChapterGenre", + column: "GenresId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChapterGenre"); + + migrationBuilder.DropColumn( + name: "Language", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "Summary", + table: "Chapter"); + + migrationBuilder.AddColumn( + name: "GenreId", + table: "Chapter", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "FullscreenMode", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateIndex( + name: "IX_Chapter_GenreId", + table: "Chapter", + column: "GenreId"); + + migrationBuilder.AddForeignKey( + name: "FK_Chapter_Genre_GenreId", + table: "Chapter", + column: "GenreId", + principalTable: "Genre", + principalColumn: "Id"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index fe1d6f77c..9a94a1795 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -186,9 +186,6 @@ namespace API.Data.Migrations b.Property("BookReaderTapToPaginate") .HasColumnType("INTEGER"); - b.Property("FullscreenMode") - .HasColumnType("INTEGER"); - b.Property("PageSplitOption") .HasColumnType("INTEGER"); @@ -311,12 +308,12 @@ namespace API.Data.Migrations b.Property("Created") .HasColumnType("TEXT"); - b.Property("GenreId") - .HasColumnType("INTEGER"); - b.Property("IsSpecial") .HasColumnType("INTEGER"); + b.Property("Language") + .HasColumnType("TEXT"); + b.Property("LastModified") .HasColumnType("TEXT"); @@ -332,6 +329,9 @@ namespace API.Data.Migrations b.Property("ReleaseDate") .HasColumnType("TEXT"); + b.Property("Summary") + .HasColumnType("TEXT"); + b.Property("Title") .HasColumnType("TEXT"); @@ -343,8 +343,6 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.HasIndex("GenreId"); - b.HasIndex("VolumeId"); b.ToTable("Chapter"); @@ -749,6 +747,21 @@ namespace API.Data.Migrations 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") @@ -1000,10 +1013,6 @@ namespace API.Data.Migrations modelBuilder.Entity("API.Entities.Chapter", b => { - b.HasOne("API.Entities.Genre", null) - .WithMany("Chapters") - .HasForeignKey("GenreId"); - b.HasOne("API.Entities.Volume", "Volume") .WithMany("Chapters") .HasForeignKey("VolumeId") @@ -1129,6 +1138,21 @@ namespace API.Data.Migrations .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) @@ -1280,11 +1304,6 @@ namespace API.Data.Migrations b.Navigation("Files"); }); - modelBuilder.Entity("API.Entities.Genre", b => - { - b.Navigation("Chapters"); - }); - modelBuilder.Entity("API.Entities.Library", b => { b.Navigation("Folders"); diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 1537cd27e..2c4d2647a 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -156,6 +156,11 @@ public class SeriesRepository : ISeriesRepository .Include(s => s.Volumes) .ThenInclude(v => v.Chapters) + .ThenInclude(c => c.Genres) + + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(c => c.Tags) .Include(s => s.Volumes) .ThenInclude(v => v.Chapters) @@ -186,7 +191,12 @@ public class SeriesRepository : ISeriesRepository .Include(s => s.Volumes) .ThenInclude(v => v.Chapters) - .ThenInclude(cm => cm.Tags) + .ThenInclude(c => c.Tags) + + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(c => c.Genres) + .Include(s => s.Metadata) .ThenInclude(m => m.Tags) @@ -590,6 +600,7 @@ public class SeriesRepository : ISeriesRepository .Include(m => m.People) .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) + .AsSplitQuery() .SingleOrDefaultAsync(); if (metadataDto != null) diff --git a/API/Entities/AppUserBookmark.cs b/API/Entities/AppUserBookmark.cs index 7674c419c..81a26a08b 100644 --- a/API/Entities/AppUserBookmark.cs +++ b/API/Entities/AppUserBookmark.cs @@ -12,10 +12,11 @@ namespace API.Entities public int VolumeId { get; set; } public int SeriesId { get; set; } public int ChapterId { get; set; } + /// /// Filename in the Bookmark Directory /// - public string FileName { get; set; } + public string FileName { get; set; } = string.Empty; // Relationships diff --git a/API/Entities/Chapter.cs b/API/Entities/Chapter.cs index 5061954af..56c3a259e 100644 --- a/API/Entities/Chapter.cs +++ b/API/Entities/Chapter.cs @@ -56,12 +56,24 @@ namespace API.Entities /// Date which chapter was released /// public DateTime ReleaseDate { get; set; } + /// + /// Summary for the Chapter/Issue + /// + public string Summary { get; set; } + /// + /// Language for the Chapter/Issue + /// + public string Language { get; set; } /// /// All people attached at a Chapter level. Usually Comics will have different people per issue. /// public ICollection People { get; set; } = new List(); + /// + /// Genres for the Chapter + /// + public ICollection Genres { get; set; } = new List(); public ICollection Tags { get; set; } = new List(); diff --git a/API/Entities/MangaFile.cs b/API/Entities/MangaFile.cs index fdd8bb23d..8cd99f3e1 100644 --- a/API/Entities/MangaFile.cs +++ b/API/Entities/MangaFile.cs @@ -23,6 +23,7 @@ namespace API.Entities /// /// Last time underlying file was modified /// + /// This gets updated anytime the file is scanned public DateTime LastModified { get; set; } @@ -32,11 +33,10 @@ namespace API.Entities /// - /// Updates the Last Modified time of the underlying file + /// Updates the Last Modified time of the underlying file to the LastWriteTime /// public void UpdateLastModified() { - // Should this be DateTime.Now ? LastModified = File.GetLastWriteTime(FilePath); } } diff --git a/API/Helpers/CacheHelper.cs b/API/Helpers/CacheHelper.cs index 153e73109..80a63490d 100644 --- a/API/Helpers/CacheHelper.cs +++ b/API/Helpers/CacheHelper.cs @@ -43,7 +43,7 @@ public class CacheHelper : ICacheHelper if (isCoverLocked && fileExists) return false; if (forceUpdate) return true; if (firstFile == null) return true; - return (_fileService.HasFileBeenModifiedSince(coverPath, chapterCreated)) || !fileExists; + return (_fileService.HasFileBeenModifiedSince(firstFile.FilePath, firstFile.LastModified)) || !fileExists; } /// diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 5ebce257d..975da1261 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -28,7 +28,7 @@ public interface IMetadataService /// Task RefreshMetadata(int libraryId, bool forceUpdate = false); /// - /// Performs a forced refresh of metatdata just for a series and it's nested entities + /// Performs a forced refresh of metadata just for a series and it's nested entities /// /// /// @@ -76,18 +76,17 @@ public class MetadataService : IMetadataService return true; } - private void UpdateChapterMetadata(Chapter chapter, ICollection allPeople, ICollection allTags, bool forceUpdate) + private void UpdateChapterMetadata(Chapter chapter, ICollection allPeople, ICollection allTags, ICollection allGenres, bool forceUpdate) { var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) return; - UpdateChapterFromComicInfo(chapter, allPeople, allTags, firstFile); + UpdateChapterFromComicInfo(chapter, allPeople, allTags, allGenres, firstFile); firstFile.UpdateLastModified(); } - private void UpdateChapterFromComicInfo(Chapter chapter, ICollection allPeople, ICollection allTags, MangaFile firstFile) + private void UpdateChapterFromComicInfo(Chapter chapter, ICollection allPeople, ICollection allTags, ICollection allGenres, MangaFile firstFile) { - // TODO: Think about letting the higher level loop have access for series to avoid duplicate IO operations var comicInfo = _readingItemService.GetComicInfo(firstFile.FilePath, firstFile.Format); if (comicInfo == null) return; @@ -196,6 +195,14 @@ public class MetadataService : IMetadataService PersonHelper.UpdatePeople(allPeople, people, PersonRole.Publisher, person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); } + + if (!string.IsNullOrEmpty(comicInfo.Genre)) + { + var genres = comicInfo.Genre.Split(","); + GenreHelper.KeepOnlySameGenreBetweenLists(chapter.Genres, genres.Select(g => DbFactory.Genre(g, false)).ToList()); + GenreHelper.UpdateGenre(allGenres, genres, false, + genre => chapter.Genres.Add(genre)); + } } @@ -253,17 +260,44 @@ public class MetadataService : IMetadataService series.CoverImage = firstCover?.CoverImage ?? coverImage; } - private void UpdateSeriesMetadata(Series series, ICollection allPeople, ICollection allGenres, ICollection allTags, bool forceUpdate) + private static void UpdateSeriesMetadata(Series series, ICollection allPeople, ICollection allGenres, ICollection allTags, bool forceUpdate) { var isBook = series.Library.Type == LibraryType.Book; var firstVolume = series.Volumes.OrderBy(c => c.Number, new ChapterSortComparer()).FirstWithChapters(isBook); var firstChapter = firstVolume?.Chapters.GetFirstChapterWithFiles(); var firstFile = firstChapter?.Files.FirstOrDefault(); - if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(firstChapter, forceUpdate, firstFile)) return; + if (firstFile == null) return; if (Parser.Parser.IsPdf(firstFile.FilePath)) return; - foreach (var chapter in series.Volumes.SelectMany(volume => volume.Chapters)) + var chapters = series.Volumes.SelectMany(volume => volume.Chapters).ToList(); + + // Update Metadata based on Chapter metadata + series.Metadata.ReleaseYear = chapters.Min(c => c.ReleaseDate.Year); + + if (series.Metadata.ReleaseYear < 1000) + { + // Not a valid year, default to 0 + series.Metadata.ReleaseYear = 0; + } + + // Set the AgeRating as highest in all the comicInfos + series.Metadata.AgeRating = chapters.Max(chapter => chapter.AgeRating); + + + if (!string.IsNullOrEmpty(firstChapter.Summary)) + { + series.Metadata.Summary = firstChapter.Summary; + } + + if (!string.IsNullOrEmpty(firstChapter.Language)) + { + series.Metadata.Language = firstChapter.Language; + } + + + // Handle People + foreach (var chapter in chapters) { PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Writer).Select(p => p.Name), PersonRole.Writer, person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); @@ -297,49 +331,14 @@ public class MetadataService : IMetadataService TagHelper.UpdateTag(allTags, chapter.Tags.Select(t => t.Title), false, (tag, added) => TagHelper.AddTagIfNotExists(series.Metadata.Tags, tag)); + + GenreHelper.UpdateGenre(allGenres, chapter.Genres.Select(t => t.Title), false, genre => + GenreHelper.AddGenreIfNotExists(series.Metadata.Genres, genre)); } - var comicInfos = series.Volumes - .SelectMany(volume => volume.Chapters) - .OrderBy(c => double.Parse(c.Number), new ChapterSortComparer()) - .SelectMany(c => c.Files) - .Select(file => _readingItemService.GetComicInfo(file.FilePath, file.Format)) - .Where(ci => ci != null) - .ToList(); - - var comicInfo = comicInfos.FirstOrDefault(); - if (!string.IsNullOrEmpty(comicInfo?.Summary)) - { - series.Metadata.Summary = comicInfo.Summary; - } - - if (!string.IsNullOrEmpty(comicInfo?.LanguageISO)) - { - series.Metadata.Language = comicInfo.LanguageISO; - } - - // Set the AgeRating as highest in all the comicInfos - series.Metadata.AgeRating = comicInfos.Max(i => ComicInfo.ConvertAgeRatingToEnum(comicInfo?.AgeRating)); - series.Metadata.ReleaseYear = series.Volumes - .SelectMany(volume => volume.Chapters).Min(c => c.ReleaseDate.Year); - - if (series.Metadata.ReleaseYear < 1000) - { - // Not a valid year, default to 0 - series.Metadata.ReleaseYear = 0; - } - - var genres = comicInfos.SelectMany(i => i?.Genre.Split(",")).Distinct().ToList(); - var tags = comicInfos.SelectMany(i => i?.Tags.Split(",")).Distinct().ToList(); - var people = series.Volumes.SelectMany(volume => volume.Chapters).SelectMany(c => c.People).ToList(); - - + var people = chapters.SelectMany(c => c.People).ToList(); PersonHelper.KeepOnlySamePeopleBetweenLists(series.Metadata.People, people, person => series.Metadata.People.Remove(person)); - - GenreHelper.UpdateGenre(allGenres, genres, false, genre => GenreHelper.AddGenreIfNotExists(series.Metadata.Genres, genre)); - GenreHelper.KeepOnlySameGenreBetweenLists(series.Metadata.Genres, genres.Select(g => DbFactory.Genre(g, false)).ToList(), - genre => series.Metadata.Genres.Remove(genre)); } /// @@ -352,20 +351,34 @@ public class MetadataService : IMetadataService _logger.LogDebug("[MetadataService] Processing series {SeriesName}", series.OriginalName); try { - var volumeUpdated = false; + var volumeIndex = 0; + var firstVolumeUpdated = false; foreach (var volume in series.Volumes) { - var chapterUpdated = false; + var firstChapterUpdated = false; // This only needs to be FirstChapter updated + var index = 0; foreach (var chapter in volume.Chapters) { - chapterUpdated = UpdateChapterCoverImage(chapter, forceUpdate); - UpdateChapterMetadata(chapter, allPeople, allTags, forceUpdate || chapterUpdated); + var chapterUpdated = UpdateChapterCoverImage(chapter, forceUpdate); + // If cover was update, either the file has changed or first scan and we should force a metadata update + UpdateChapterMetadata(chapter, allPeople, allTags, allGenres, forceUpdate || chapterUpdated); + if (index == 0 && chapterUpdated) + { + firstChapterUpdated = true; + } + + index++; } - volumeUpdated = UpdateVolumeCoverImage(volume, chapterUpdated || forceUpdate); + var volumeUpdated = UpdateVolumeCoverImage(volume, firstChapterUpdated || forceUpdate); + if (volumeIndex == 0 && volumeUpdated) + { + firstVolumeUpdated = true; + } + volumeIndex++; } - UpdateSeriesCoverImage(series, volumeUpdated || forceUpdate); + UpdateSeriesCoverImage(series, firstVolumeUpdated || forceUpdate); UpdateSeriesMetadata(series, allPeople, allGenres, allTags, forceUpdate); } catch (Exception ex) diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 624ed5462..51dbdff85 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -641,6 +641,7 @@ public class ScannerService : IScannerService existingFile.Format = info.Format; if (!_fileService.HasFileBeenModifiedSince(existingFile.FilePath, existingFile.LastModified) && existingFile.Pages != 0) return; existingFile.Pages = _readingItemService.GetNumberOfPages(info.FullFilePath, info.Format); + //existingFile.UpdateLastModified(); // We skip updating DB here so that metadata refresh can do it } else { diff --git a/UI/Web/src/app/admin/changelog/changelog.component.html b/UI/Web/src/app/admin/changelog/changelog.component.html index 9655fe18e..10709f0ed 100644 --- a/UI/Web/src/app/admin/changelog/changelog.component.html +++ b/UI/Web/src/app/admin/changelog/changelog.component.html @@ -3,11 +3,11 @@

{{update.updateTitle}}  - Installed - Available + Installed + Available

Published: {{update.publishDate | date: 'short'}}
- +

           Download
         
diff --git a/UI/Web/src/app/admin/changelog/changelog.component.ts b/UI/Web/src/app/admin/changelog/changelog.component.ts index e24ad9845..86724e92e 100644 --- a/UI/Web/src/app/admin/changelog/changelog.component.ts +++ b/UI/Web/src/app/admin/changelog/changelog.component.ts @@ -11,13 +11,26 @@ export class ChangelogComponent implements OnInit { updates: Array = []; isLoading: boolean = true; + installedVersion: string = ''; constructor(private serverService: ServerService) { } ngOnInit(): void { - this.serverService.getChangelog().subscribe(updates => { - this.updates = updates; - this.isLoading = false; + + this.serverService.getServerInfo().subscribe(info => { + this.installedVersion = info.kavitaVersion; + this.serverService.getChangelog().subscribe(updates => { + this.updates = updates; + this.isLoading = false; + + if (this.updates.filter(u => u.updateVersion === this.installedVersion).length === 0) { + // User is on a nightly version. Tell them the last stable is installed + this.installedVersion = this.updates[0].updateVersion; + } + }); }); + + + } } diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html index c44e389b8..ac889f95c 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html @@ -38,11 +38,13 @@
+ This is library agnostic
- +   + {{item.title}} @@ -70,7 +72,8 @@
- +   + {{item.title}} diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts index eb301641d..ebb6136a4 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts @@ -84,7 +84,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { libraries: Array> = []; genres: Array> = []; persons: Array> = []; - collectionTags: Array> = []; + //collectionTags: Array> = []; readProgressGroup!: FormGroup; sortGroup!: FormGroup; @@ -329,9 +329,11 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { return options.filter(m => m.title.toLowerCase() === f); } if (this.filterSettings.presetCollectionId > 0) { - this.collectionSettings.savedData = this.collectionTags.filter(item => item.value.id === this.filterSettings.presetCollectionId); - this.filter.collectionTags = this.collectionSettings.savedData.map(item => item.value.id); - this.resetTypeaheads.next(true); + this.collectionSettings.fetchFn('').subscribe(tags => { + this.collectionSettings.savedData = tags.filter(item => item.value.id === this.filterSettings.presetCollectionId); + this.filter.collectionTags = this.collectionSettings.savedData.map(item => item.value.id); + this.resetTypeaheads.next(true); + }); } }