diff --git a/API.Benchmark/EpubBenchmark.cs b/API.Benchmark/EpubBenchmark.cs new file mode 100644 index 000000000..7739ac38c --- /dev/null +++ b/API.Benchmark/EpubBenchmark.cs @@ -0,0 +1,68 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Services; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Order; +using HtmlAgilityPack; +using VersOne.Epub; + +namespace API.Benchmark; + +[MemoryDiagnoser] +[Orderer(SummaryOrderPolicy.FastestToSlowest)] +[RankColumn] +[SimpleJob(launchCount: 1, warmupCount: 3, targetCount: 5, invocationCount: 100, id: "Epub"), ShortRunJob] +public class EpubBenchmark +{ + [Benchmark] + public async Task GetWordCount_PassByString() + { + using var book = await EpubReader.OpenBookAsync("Data/book-test.epub", BookService.BookReaderOptions); + foreach (var bookFile in book.Content.Html.Values) + { + Console.WriteLine(GetBookWordCount_PassByString(await bookFile.ReadContentAsTextAsync())); + ; + } + } + + [Benchmark] + public async Task GetWordCount_PassByRef() + { + using var book = await EpubReader.OpenBookAsync("Data/book-test.epub", BookService.BookReaderOptions); + foreach (var bookFile in book.Content.Html.Values) + { + Console.WriteLine(await GetBookWordCount_PassByRef(bookFile)); + } + } + + private static int GetBookWordCount_PassByString(string fileContents) + { + var doc = new HtmlDocument(); + doc.LoadHtml(fileContents); + var delimiter = new char[] {' '}; + + return doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]") + .Select(node => node.InnerText) + .Select(text => text.Split(delimiter, StringSplitOptions.RemoveEmptyEntries) + .Where(s => char.IsLetter(s[0]))) + .Select(words => words.Count()) + .Where(wordCount => wordCount > 0) + .Sum(); + } + + private static async Task GetBookWordCount_PassByRef(EpubContentFileRef bookFile) + { + var doc = new HtmlDocument(); + doc.LoadHtml(await bookFile.ReadContentAsTextAsync()); + var delimiter = new char[] {' '}; + + return doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]") + .Select(node => node.InnerText) + .Select(text => text.Split(delimiter, StringSplitOptions.RemoveEmptyEntries) + .Where(s => char.IsLetter(s[0]))) + .Select(words => words.Count()) + .Where(wordCount => wordCount > 0) + .Sum(); + } +} diff --git a/API.Benchmark/Program.cs b/API.Benchmark/Program.cs index c3ef1b605..4a659a1b8 100644 --- a/API.Benchmark/Program.cs +++ b/API.Benchmark/Program.cs @@ -14,7 +14,8 @@ namespace API.Benchmark { //BenchmarkRunner.Run(); //BenchmarkRunner.Run(); - BenchmarkRunner.Run(); + //BenchmarkRunner.Run(); + BenchmarkRunner.Run(); } } diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 85880a38d..22ac12faf 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -166,6 +166,14 @@ namespace API.Controllers return Ok(); } + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("analyze")] + public ActionResult Analyze(int libraryId) + { + _taskScheduler.AnalyzeFilesForLibrary(libraryId); + return Ok(); + } + [HttpGet("libraries")] public async Task>> GetLibrariesForUser() { diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 34e90d818..613bc6698 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -269,6 +269,14 @@ namespace API.Controllers return Ok(); } + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("analyze")] + public ActionResult AnalyzeSeries(RefreshSeriesDto refreshSeriesDto) + { + _taskScheduler.AnalyzeFilesForSeries(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId); + return Ok(); + } + [HttpGet("metadata")] public async Task> GetSeriesMetadata(int seriesId) { diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index e22046b60..63f0f6fd4 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -61,5 +61,9 @@ namespace API.DTOs /// /// Metadata field public string TitleName { get; set; } + /// + /// Number of Words for this chapter. Only applies to Epub + /// + public long WordCount { get; set; } } } diff --git a/API/DTOs/SeriesDto.cs b/API/DTOs/SeriesDto.cs index a5756ceca..5b69c4ab9 100644 --- a/API/DTOs/SeriesDto.cs +++ b/API/DTOs/SeriesDto.cs @@ -40,6 +40,10 @@ namespace API.DTOs public bool NameLocked { get; set; } public bool SortNameLocked { get; set; } public bool LocalizedNameLocked { get; set; } + /// + /// Total number of words for the series. Only applies to epubs. + /// + public long WordCount { get; set; } public int LibraryId { get; set; } public string LibraryName { get; set; } diff --git a/API/Data/Migrations/20220524172543_WordCount.Designer.cs b/API/Data/Migrations/20220524172543_WordCount.Designer.cs new file mode 100644 index 000000000..04f2b5f38 --- /dev/null +++ b/API/Data/Migrations/20220524172543_WordCount.Designer.cs @@ -0,0 +1,1532 @@ +// +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("20220524172543_WordCount")] + partial class WordCount + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.5"); + + 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("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .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("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PageLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .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("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("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("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("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.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("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("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("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("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + 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("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.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("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.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.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.ClientCascade) + .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.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("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/20220524172543_WordCount.cs b/API/Data/Migrations/20220524172543_WordCount.cs new file mode 100644 index 000000000..2828985b6 --- /dev/null +++ b/API/Data/Migrations/20220524172543_WordCount.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class WordCount : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "WordCount", + table: "Series", + type: "INTEGER", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "WordCount", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: 0L); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "WordCount", + table: "Series"); + + migrationBuilder.DropColumn( + name: "WordCount", + table: "Chapter"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index a8b5527d5..d08dbc1d1 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "6.0.4"); + modelBuilder.HasAnnotation("ProductVersion", "6.0.5"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -368,6 +368,9 @@ namespace API.Data.Migrations b.Property("VolumeId") .HasColumnType("INTEGER"); + b.Property("WordCount") + .HasColumnType("INTEGER"); + b.HasKey("Id"); b.HasIndex("VolumeId"); @@ -777,6 +780,9 @@ namespace API.Data.Migrations b.Property("SortNameLocked") .HasColumnType("INTEGER"); + b.Property("WordCount") + .HasColumnType("INTEGER"); + b.HasKey("Id"); b.HasIndex("LibraryId"); diff --git a/API/Entities/Chapter.cs b/API/Entities/Chapter.cs index e6e7926e3..dec80fc85 100644 --- a/API/Entities/Chapter.cs +++ b/API/Entities/Chapter.cs @@ -72,6 +72,11 @@ namespace API.Entities /// public int Count { get; set; } = 0; + /// + /// Total words in a Chapter (books only) + /// + public long WordCount { get; set; } + /// /// All people attached at a Chapter level. Usually Comics will have different people per issue. diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index 1ddd8f082..83accd933 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -65,6 +65,11 @@ public class Series : IEntityDate /// public DateTime LastChapterAdded { get; set; } + /// + /// Total words in a Series (books only) + /// + public long WordCount { get; set; } + public SeriesMetadata Metadata { get; set; } public ICollection Ratings { get; set; } = new List(); diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 63e9dfdb9..2ebaec592 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -3,6 +3,7 @@ using API.Data; using API.Helpers; using API.Services; using API.Services.Tasks; +using API.Services.Tasks.Metadata; using API.SignalR; using API.SignalR.Presence; using Kavita.Common; @@ -20,15 +21,18 @@ namespace API.Extensions public static void AddApplicationServices(this IServiceCollection services, IConfiguration config, IWebHostEnvironment env) { services.AddAutoMapper(typeof(AutoMapperProfiles).Assembly); - services.AddScoped(); - services.AddScoped(); + + services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); services.AddScoped(); - services.AddScoped(); - services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -43,10 +47,11 @@ namespace API.Extensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index dcd356d88..da0560848 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -12,7 +12,9 @@ using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Helpers; +using API.Services.Tasks.Metadata; using API.SignalR; +using Hangfire; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; @@ -194,6 +196,8 @@ public class MetadataService : IMetadataService /// This can be heavy on memory first run /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image + [DisableConcurrentExecution(timeoutInSeconds: 360)] + [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task RefreshMetadata(int libraryId, bool forceUpdate = false) { var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None); @@ -256,10 +260,10 @@ public class MetadataService : IMetadataService await RemoveAbandonedMetadataKeys(); - _logger.LogInformation("[MetadataService] Updated metadata for {SeriesNumber} series in library {LibraryName} in {ElapsedMilliseconds} milliseconds total", chunkInfo.TotalSize, library.Name, totalTime); } + private async Task RemoveAbandonedMetadataKeys() { await _unitOfWork.TagRepository.RemoveAllTagNoLongerAssociated(); diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 705c1010a..58f70a94c 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -6,6 +6,7 @@ using API.Data; using API.Entities.Enums; using API.Helpers.Converters; using API.Services.Tasks; +using API.Services.Tasks.Metadata; using Hangfire; using Hangfire.Storage; using Microsoft.Extensions.Logging; @@ -22,6 +23,8 @@ public interface ITaskScheduler void RefreshMetadata(int libraryId, bool forceUpdate = true); void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false); void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false); + void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false); + void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false); void CancelStatsTasks(); Task RunStatCollection(); void ScanSiteThemes(); @@ -41,6 +44,7 @@ public class TaskScheduler : ITaskScheduler private readonly IStatsService _statsService; private readonly IVersionUpdaterService _versionUpdaterService; private readonly IThemeService _themeService; + private readonly IWordCountAnalyzerService _wordCountAnalyzerService; public static BackgroundJobServer Client => new BackgroundJobServer(); private static readonly Random Rnd = new Random(); @@ -49,7 +53,7 @@ public class TaskScheduler : ITaskScheduler public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService, IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService, ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService, - IThemeService themeService) + IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService) { _cacheService = cacheService; _logger = logger; @@ -61,6 +65,7 @@ public class TaskScheduler : ITaskScheduler _statsService = statsService; _versionUpdaterService = versionUpdaterService; _themeService = themeService; + _wordCountAnalyzerService = wordCountAnalyzerService; } public async Task ScheduleTasks() @@ -111,6 +116,11 @@ public class TaskScheduler : ITaskScheduler RecurringJob.AddOrUpdate("report-stats", () => _statsService.Send(), Cron.Daily(Rnd.Next(0, 22)), TimeZoneInfo.Local); } + public void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false) + { + BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanLibrary(libraryId, forceUpdate)); + } + public void CancelStatsTasks() { _logger.LogDebug("Cancelling/Removing StatsTasks"); @@ -182,6 +192,12 @@ public class TaskScheduler : ITaskScheduler BackgroundJob.Enqueue(() => _scannerService.ScanSeries(libraryId, seriesId, CancellationToken.None)); } + public void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false) + { + _logger.LogInformation("Enqueuing analyze files scan for: {SeriesId}", seriesId); + BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(libraryId, seriesId, forceUpdate)); + } + public void BackupDatabase() { BackgroundJob.Enqueue(() => _backupService.BackupDatabase()); diff --git a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs new file mode 100644 index 000000000..aa81f1613 --- /dev/null +++ b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs @@ -0,0 +1,218 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.Entities; +using API.Entities.Enums; +using API.Helpers; +using API.SignalR; +using Hangfire; +using HtmlAgilityPack; +using Microsoft.Extensions.Logging; +using VersOne.Epub; + +namespace API.Services.Tasks.Metadata; + +public interface IWordCountAnalyzerService +{ + Task ScanLibrary(int libraryId, bool forceUpdate = false); + Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false); +} + +/// +/// This service is a metadata task that generates information around time to read +/// +public class WordCountAnalyzerService : IWordCountAnalyzerService +{ + private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; + private readonly IEventHub _eventHub; + private readonly ICacheHelper _cacheHelper; + + public WordCountAnalyzerService(ILogger logger, IUnitOfWork unitOfWork, IEventHub eventHub, + ICacheHelper cacheHelper) + { + _logger = logger; + _unitOfWork = unitOfWork; + _eventHub = eventHub; + _cacheHelper = cacheHelper; + } + + [DisableConcurrentExecution(timeoutInSeconds: 360)] + [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] + public async Task ScanLibrary(int libraryId, bool forceUpdate = false) + { + var sw = Stopwatch.StartNew(); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None); + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.WordCountAnalyzerProgressEvent(libraryId, 0F, ProgressEventType.Started, string.Empty)); + + var chunkInfo = await _unitOfWork.SeriesRepository.GetChunkInfo(library.Id); + var stopwatch = Stopwatch.StartNew(); + var totalTime = 0L; + _logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize); + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.WordCountAnalyzerProgressEvent(library.Id, 0F, ProgressEventType.Started, $"Starting {library.Name}")); + + for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) + { + if (chunkInfo.TotalChunks == 0) continue; + totalTime += stopwatch.ElapsedMilliseconds; + stopwatch.Restart(); + + _logger.LogInformation("[MetadataService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}", + chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize); + + var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id, + new UserParams() + { + PageNumber = chunk, + PageSize = chunkInfo.ChunkSize + }); + _logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count); + + var seriesIndex = 0; + foreach (var series in nonLibrarySeries) + { + var index = chunk * seriesIndex; + var progress = Math.Max(0F, Math.Min(1F, index * 1F / chunkInfo.TotalSize)); + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.WordCountAnalyzerProgressEvent(library.Id, progress, ProgressEventType.Updated, series.Name)); + + try + { + await ProcessSeries(series, forceUpdate, false); + } + catch (Exception ex) + { + _logger.LogError(ex, "[MetadataService] There was an exception during metadata refresh for {SeriesName}", series.Name); + } + seriesIndex++; + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + } + + _logger.LogInformation( + "[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", + chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name); + } + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.WordCountAnalyzerProgressEvent(library.Id, 1F, ProgressEventType.Ended, $"Complete")); + + + _logger.LogInformation("[WordCountAnalyzerService] Updated metadata for {LibraryName} in {ElapsedMilliseconds} milliseconds", library.Name, sw.ElapsedMilliseconds); + + } + + public async Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false) + { + var sw = Stopwatch.StartNew(); + var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); + if (series == null) + { + _logger.LogError("[WordCountAnalyzerService] Series {SeriesId} was not found on Library {LibraryId}", seriesId, libraryId); + return; + } + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.WordCountAnalyzerProgressEvent(libraryId, 0F, ProgressEventType.Started, series.Name)); + + await ProcessSeries(series); + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + } + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.WordCountAnalyzerProgressEvent(libraryId, 1F, ProgressEventType.Ended, series.Name)); + + _logger.LogInformation("[WordCountAnalyzerService] Updated metadata for {SeriesName} in {ElapsedMilliseconds} milliseconds", series.Name, sw.ElapsedMilliseconds); + } + + private async Task ProcessSeries(Series series, bool forceUpdate = false, bool useFileName = true) + { + if (series.Format != MangaFormat.Epub) return; + + long totalSum = 0; + + foreach (var chapter in series.Volumes.SelectMany(v => v.Chapters)) + { + // This compares if it's changed since a file scan only + if (!_cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, + chapter.Files.FirstOrDefault()) && chapter.WordCount != 0) + continue; + + long sum = 0; + var fileCounter = 1; + foreach (var file in chapter.Files.Select(file => file.FilePath)) + { + var pageCounter = 1; + try + { + using var book = await EpubReader.OpenBookAsync(file, BookService.BookReaderOptions); + + var totalPages = book.Content.Html.Values; + foreach (var bookPage in totalPages) + { + var progress = Math.Max(0F, + Math.Min(1F, (fileCounter * pageCounter) * 1F / (chapter.Files.Count * totalPages.Count))); + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.WordCountAnalyzerProgressEvent(series.LibraryId, progress, + ProgressEventType.Updated, useFileName ? file : series.Name)); + sum += await GetWordCountFromHtml(bookPage); + pageCounter++; + } + + fileCounter++; + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an error reading an epub file for word count, series skipped"); + await _eventHub.SendMessageAsync(MessageFactory.Error, + MessageFactory.ErrorEvent("There was an issue counting words on an epub", + $"{series.Name} - {file}")); + return; + } + + } + + chapter.WordCount = sum; + _unitOfWork.ChapterRepository.Update(chapter); + totalSum += sum; + } + + series.WordCount = totalSum; + _unitOfWork.SeriesRepository.Update(series); + } + + + private static async Task GetWordCountFromHtml(EpubContentFileRef bookFile) + { + var doc = new HtmlDocument(); + doc.LoadHtml(await bookFile.ReadContentAsTextAsync()); + + var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]"); + if (textNodes == null) return 0; + + return textNodes + .Select(node => node.InnerText) + .Select(text => text.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Where(s => char.IsLetter(s[0]))) + .Select(words => words.Count()) + .Where(wordCount => wordCount > 0) + .Sum(); + } + + +} diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index fc40081ab..4c19fc0ec 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -14,6 +14,7 @@ using API.Entities.Enums; using API.Extensions; using API.Helpers; using API.Parser; +using API.Services.Tasks.Metadata; using API.Services.Tasks.Scanner; using API.SignalR; using Hangfire; @@ -43,11 +44,12 @@ public class ScannerService : IScannerService private readonly IDirectoryService _directoryService; private readonly IReadingItemService _readingItemService; private readonly ICacheHelper _cacheHelper; + private readonly IWordCountAnalyzerService _wordCountAnalyzerService; public ScannerService(IUnitOfWork unitOfWork, ILogger logger, IMetadataService metadataService, ICacheService cacheService, IEventHub eventHub, IFileService fileService, IDirectoryService directoryService, IReadingItemService readingItemService, - ICacheHelper cacheHelper) + ICacheHelper cacheHelper, IWordCountAnalyzerService wordCountAnalyzerService) { _unitOfWork = unitOfWork; _logger = logger; @@ -58,6 +60,7 @@ public class ScannerService : IScannerService _directoryService = directoryService; _readingItemService = readingItemService; _cacheHelper = cacheHelper; + _wordCountAnalyzerService = wordCountAnalyzerService; } [DisableConcurrentExecution(timeoutInSeconds: 360)] @@ -71,6 +74,15 @@ public class ScannerService : IScannerService var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders); var folderPaths = library.Folders.Select(f => f.Path).ToList(); + var seriesFolderPaths = (await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId)) + .Select(f => _directoryService.FileSystem.FileInfo.FromFileName(f.FilePath).Directory.FullName) + .ToList(); + + if (!await CheckMounts(library.Name, seriesFolderPaths)) + { + _logger.LogCritical("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); + return; + } if (!await CheckMounts(library.Name, library.Folders.Select(f => f.Path).ToList())) { @@ -82,10 +94,15 @@ public class ScannerService : IScannerService var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync(); var allTags = await _unitOfWork.TagRepository.GetAllTagsAsync(); - var dirs = _directoryService.FindHighestDirectoriesFromFiles(folderPaths, files.Select(f => f.FilePath).ToList()); + var seriesDirs = _directoryService.FindHighestDirectoriesFromFiles(seriesFolderPaths, files.Select(f => f.FilePath).ToList()); + if (seriesDirs.Keys.Count == 0) + { + _logger.LogDebug("Scan Series has files spread outside a main series folder. Defaulting to library folder"); + seriesDirs = _directoryService.FindHighestDirectoriesFromFiles(folderPaths, files.Select(f => f.FilePath).ToList()); + } _logger.LogInformation("Beginning file scan on {SeriesName}", series.Name); - var (totalFiles, scanElapsedTime, parsedSeries) = await ScanFiles(library, dirs.Keys); + var (totalFiles, scanElapsedTime, parsedSeries) = await ScanFiles(library, seriesDirs.Keys); @@ -117,10 +134,10 @@ public class ScannerService : IScannerService // We need to do an additional check for an edge case: If the scan ran and the files do not match the existing Series name, then it is very likely, // the files have crap naming and if we don't correct, the series will get deleted due to the parser not being able to fallback onto folder parsing as the root // is the series folder. - var existingFolder = dirs.Keys.FirstOrDefault(key => key.Contains(series.OriginalName)); - if (dirs.Keys.Count == 1 && !string.IsNullOrEmpty(existingFolder)) + var existingFolder = seriesDirs.Keys.FirstOrDefault(key => key.Contains(series.OriginalName)); + if (seriesDirs.Keys.Count == 1 && !string.IsNullOrEmpty(existingFolder)) { - dirs = new Dictionary(); + seriesDirs = new Dictionary(); var path = Directory.GetParent(existingFolder)?.FullName; if (!folderPaths.Contains(path) || !folderPaths.Any(p => p.Contains(path ?? string.Empty))) { @@ -131,11 +148,11 @@ public class ScannerService : IScannerService } if (!string.IsNullOrEmpty(path)) { - dirs[path] = string.Empty; + seriesDirs[path] = string.Empty; } } - var (totalFiles2, scanElapsedTime2, parsedSeries2) = await ScanFiles(library, dirs.Keys); + var (totalFiles2, scanElapsedTime2, parsedSeries2) = await ScanFiles(library, seriesDirs.Keys); _logger.LogInformation("{SeriesName} has bad naming convention, forcing rescan at a higher directory", series.OriginalName); totalFiles += totalFiles2; scanElapsedTime += scanElapsedTime2; @@ -303,10 +320,8 @@ public class ScannerService : IScannerService await CleanupDbEntities(); - // await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress, - // MessageFactory.ScanLibraryProgressEvent(libraryId, 1F)); - BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, false)); + BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanLibrary(libraryId, false)); } private async Task>>> ScanFiles(Library library, IEnumerable dirs) diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 030c69c99..952b16736 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -102,7 +102,11 @@ namespace API.SignalR /// /// When bulk bookmarks are being converted /// - public const string ConvertBookmarksProgress = "ConvertBookmarksProgress"; + private const string ConvertBookmarksProgress = "ConvertBookmarksProgress"; + /// + /// When files are being scanned to calculate word count + /// + private const string WordCountAnalyzerProgress = "WordCountAnalyzerProgress"; @@ -149,6 +153,25 @@ namespace API.SignalR }; } + + public static SignalRMessage WordCountAnalyzerProgressEvent(int libraryId, float progress, string eventType, string subtitle = "") + { + return new SignalRMessage() + { + Name = WordCountAnalyzerProgress, + Title = "Analyzing Word count", + SubTitle = subtitle, + EventType = eventType, + Progress = ProgressType.Determinate, + Body = new + { + LibraryId = libraryId, + Progress = progress, + EventTime = DateTime.Now + } + }; + } + public static SignalRMessage CoverUpdateProgressEvent(int libraryId, float progress, string eventType, string subtitle = "") { return new SignalRMessage() diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index a5b7e35b3..1223a028a 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -5757,9 +5757,9 @@ "dev": true }, "eventsource": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.1.0.tgz", - "integrity": "sha512-VSJjT5oCNrFvCS6igjzPAt5hBzQ2qPBFIbJ03zLI9SE0mxwZpMw6BfJrbFHm1a141AavMEB8JHmBhWAd66PfCg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.1.1.tgz", + "integrity": "sha512-qV5ZC0h7jYIAOhArFJgSfdyz6rALJyb270714o7ZtNnw2WSJ+eexhKtE0O8LYPRsHZHf2osHKZBxGPvm3kPkCA==", "requires": { "original": "^1.0.0" } @@ -10973,8 +10973,7 @@ "dependencies": { "ansi-regex": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "resolved": "", "dev": true }, "strip-ansi": { @@ -11179,8 +11178,7 @@ "dependencies": { "ansi-regex": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "resolved": "", "dev": true }, "ansi-styles": { @@ -11536,6 +11534,11 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true }, + "requires": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/requires/-/requires-1.0.2.tgz", + "integrity": "sha1-djBOghNFYi/j+sCwcRoeTygo8Po=" + }, "requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", diff --git a/UI/Web/package.json b/UI/Web/package.json index 8d1020079..65633c433 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -33,12 +33,14 @@ "@types/file-saver": "^2.0.5", "bootstrap": "^5.1.2", "bowser": "^2.11.0", + "eventsource": "^1.1.1", "file-saver": "^2.0.5", "lazysizes": "^5.3.2", "ng-circle-progress": "^1.6.0", "ngx-color-picker": "^12.0.0", "ngx-file-drop": "^13.0.0", "ngx-toastr": "^14.2.1", + "requires": "^1.0.2", "rxjs": "~7.5.4", "swiper": "^8.0.6", "tslib": "^2.3.1", diff --git a/UI/Web/src/app/_models/series.ts b/UI/Web/src/app/_models/series.ts index 17b489b6e..f27f15dcb 100644 --- a/UI/Web/src/app/_models/series.ts +++ b/UI/Web/src/app/_models/series.ts @@ -48,4 +48,8 @@ export interface Series { * DateTime representing last time a chapter was added to the Series */ lastChapterAdded: string; + /** + * Number of words in the series + */ + wordCount: number; } diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index b67a162bd..8532fbdc9 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -9,20 +9,53 @@ import { Volume } from '../_models/volume'; import { AccountService } from './account.service'; export enum Action { + /** + * Mark entity as read + */ MarkAsRead = 0, + /** + * Mark entity as unread + */ MarkAsUnread = 1, + /** + * Invoke a Scan Library + */ ScanLibrary = 2, + /** + * Delete the entity + */ Delete = 3, + /** + * Open edit modal + */ Edit = 4, + /** + * Open details modal + */ Info = 5, + /** + * Invoke a refresh covers + */ RefreshMetadata = 6, + /** + * Download the entity + */ Download = 7, /** - * @deprecated This is no longer supported. Use the dedicated page instead + * Invoke an Analyze Files which calculates word count + */ + AnalyzeFiles = 8, + /** + * Read in incognito mode aka no progress tracking */ - Bookmarks = 8, IncognitoRead = 9, + /** + * Add to reading list + */ AddToReadingList = 10, + /** + * Add to collection + */ AddToCollection = 11, /** * Essentially a download, but handled differently. Needed so card bubbles it up for handling @@ -31,7 +64,7 @@ export enum Action { /** * Open Series detail page for said series */ - ViewSeries = 13 + ViewSeries = 13, } export interface ActionItem { @@ -97,6 +130,13 @@ export class ActionFactoryService { requiresAdmin: true }); + this.seriesActions.push({ + action: Action.AnalyzeFiles, + title: 'Analyze Files', + callback: this.dummyCallback, + requiresAdmin: true + }); + this.seriesActions.push({ action: Action.Delete, title: 'Delete', @@ -131,6 +171,13 @@ export class ActionFactoryService { callback: this.dummyCallback, requiresAdmin: true }); + + this.libraryActions.push({ + action: Action.AnalyzeFiles, + title: 'Analyze Files', + callback: this.dummyCallback, + requiresAdmin: true + }); this.chapterActions.push({ action: Action.Edit, @@ -200,11 +247,6 @@ export class ActionFactoryService { return actions; } - filterBookmarksForFormat(action: ActionItem, series: Series) { - if (action.action === Action.Bookmarks && series?.format === MangaFormat.EPUB) return false; - return true; - } - dummyCallback(action: Action, data: any) {} _resetActions() { diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 7f05a02d8..9f2838a0c 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -64,6 +64,7 @@ export class ActionService implements OnDestroy { }); } + /** * Request a refresh of Metadata for a given Library * @param library Partial Library, must have id and name populated @@ -90,6 +91,32 @@ export class ActionService implements OnDestroy { }); } + /** + * Request an analysis of files for a given Library (currently just word count) + * @param library Partial Library, must have id and name populated + * @param callback Optional callback to perform actions after API completes + * @returns + */ + async analyzeFiles(library: Partial, callback?: LibraryActionCallback) { + if (!library.hasOwnProperty('id') || library.id === undefined) { + return; + } + + if (!await this.confirmService.alert('This is a long running process. Please give it the time to complete before invoking again.')) { + if (callback) { + callback(library); + } + return; + } + + this.libraryService.analyze(library?.id).pipe(take(1)).subscribe((res: any) => { + this.toastr.info('Library file analysis queued for ' + library.name); + if (callback) { + callback(library); + } + }); + } + /** * Mark a series as read; updates the series pagesRead * @param series Series, must have id and name populated @@ -121,7 +148,7 @@ export class ActionService implements OnDestroy { } /** - * Start a file scan for a Series (currently just does the library not the series directly) + * Start a file scan for a Series * @param series Series, must have libraryId and name populated * @param callback Optional callback to perform actions after API completes */ @@ -134,6 +161,20 @@ export class ActionService implements OnDestroy { }); } + /** + * Start a file scan for analyze files for a Series + * @param series Series, must have libraryId and name populated + * @param callback Optional callback to perform actions after API completes + */ + analyzeFilesForSeries(series: Series, callback?: SeriesActionCallback) { + this.seriesService.analyzeFiles(series.libraryId, series.id).pipe(take(1)).subscribe((res: any) => { + this.toastr.info('Scan queued for ' + series.name); + if (callback) { + callback(series); + } + }); + } + /** * Start a metadata refresh for a Series * @param series Series, must have libraryId, id and name populated diff --git a/UI/Web/src/app/_services/library.service.ts b/UI/Web/src/app/_services/library.service.ts index 697de7b1c..1936124dc 100644 --- a/UI/Web/src/app/_services/library.service.ts +++ b/UI/Web/src/app/_services/library.service.ts @@ -74,6 +74,10 @@ export class LibraryService { return this.httpClient.post(this.baseUrl + 'library/scan?libraryId=' + libraryId, {}); } + analyze(libraryId: number) { + return this.httpClient.post(this.baseUrl + 'library/analyze?libraryId=' + libraryId, {}); + } + refreshMetadata(libraryId: number) { return this.httpClient.post(this.baseUrl + 'library/refresh-metadata?libraryId=' + libraryId, {}); } diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index f99410894..591cbdfd4 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -20,6 +20,7 @@ export class MetadataService { baseUrl = environment.apiUrl; private ageRatingTypes: {[key: number]: string} | undefined = undefined; + private validLanguages: Array = []; constructor(private httpClient: HttpClient, private utilityService: UtilityService) { } @@ -81,7 +82,12 @@ export class MetadataService { * All the potential language tags there can be */ getAllValidLanguages() { - return this.httpClient.get>(this.baseUrl + 'metadata/all-languages'); + if (this.validLanguages != undefined && this.validLanguages.length > 0) { + return of(this.validLanguages); + } + return this.httpClient.get>(this.baseUrl + 'metadata/all-languages').pipe(map(l => this.validLanguages = l)); + + //return this.httpClient.get>(this.baseUrl + 'metadata/all-languages').pipe(); } getAllPeople(libraries?: Array) { diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index e62ec97a9..288e7dd01 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -145,6 +145,10 @@ export class SeriesService { return this.httpClient.post(this.baseUrl + 'series/scan', {libraryId: libraryId, seriesId: seriesId}); } + analyzeFiles(libraryId: number, seriesId: number) { + return this.httpClient.post(this.baseUrl + 'series/analyze', {libraryId: libraryId, seriesId: seriesId}); + } + getMetadata(seriesId: number) { return this.httpClient.get(this.baseUrl + 'series/metadata?seriesId=' + seriesId).pipe(map(items => { items?.collectionTags.forEach(tag => tag.coverImage = this.imageService.getCollectionCoverImage(tag.id)); diff --git a/UI/Web/src/app/app-routing.module.ts b/UI/Web/src/app/app-routing.module.ts index 6a2bfff34..4c795e4ca 100644 --- a/UI/Web/src/app/app-routing.module.ts +++ b/UI/Web/src/app/app-routing.module.ts @@ -4,8 +4,6 @@ import { AuthGuard } from './_guards/auth.guard'; import { LibraryAccessGuard } from './_guards/library-access.guard'; import { AdminGuard } from './_guards/admin.guard'; -// TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules -// TODO: Use Prefetching of LazyLoaded Modules const routes: Routes = [ { path: 'admin', @@ -72,13 +70,9 @@ const routes: Routes = [ }, ] }, - { - path: 'theme', - loadChildren: () => import('../app/dev-only/dev-only.module').then(m => m.DevOnlyModule) - }, {path: 'login', loadChildren: () => import('../app/registration/registration.module').then(m => m.RegistrationModule)}, {path: '**', pathMatch: 'full', redirectTo: 'libraries'}, - {path: '', pathMatch: 'full', redirectTo: 'libraries'}, + {path: '', pathMatch: 'full', redirectTo: 'login'}, ]; @NgModule({ diff --git a/UI/Web/src/app/cards/series-card/series-card.component.ts b/UI/Web/src/app/cards/series-card/series-card.component.ts index b202b89b7..41be7ee39 100644 --- a/UI/Web/src/app/cards/series-card/series-card.component.ts +++ b/UI/Web/src/app/cards/series-card/series-card.component.ts @@ -71,7 +71,7 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy { ngOnChanges(changes: any) { if (this.data) { - this.actions = this.actionFactoryService.getSeriesActions((action: Action, series: Series) => this.handleSeriesActionCallback(action, series)).filter(action => this.actionFactoryService.filterBookmarksForFormat(action, this.data)); + this.actions = this.actionFactoryService.getSeriesActions((action: Action, series: Series) => this.handleSeriesActionCallback(action, series)); this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.data.id)); } } @@ -102,10 +102,13 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy { this.openEditModal(series); break; case(Action.AddToReadingList): - this.actionService.addSeriesToReadingList(series, (series) => {/* No Operation */ }); + this.actionService.addSeriesToReadingList(series); break; case(Action.AddToCollection): - this.actionService.addMultipleSeriesToCollectionTag([series], () => {/* No Operation */ }); + this.actionService.addMultipleSeriesToCollectionTag([series]); + break; + case (Action.AnalyzeFiles): + this.actionService.analyzeFilesForSeries(series); break; default: break; diff --git a/UI/Web/src/app/dev-only/dev-only-routing.module.ts b/UI/Web/src/app/dev-only/dev-only-routing.module.ts deleted file mode 100644 index 759989c57..000000000 --- a/UI/Web/src/app/dev-only/dev-only-routing.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NgModule } from '@angular/core'; -import { Routes, RouterModule } from '@angular/router'; -import { ThemeTestComponent } from './theme-test/theme-test.component'; - - -const routes: Routes = [ - { - path: '', - component: ThemeTestComponent, - } -]; - - -@NgModule({ - imports: [RouterModule.forChild(routes), ], - exports: [RouterModule] -}) -export class DevOnlyRoutingModule { } diff --git a/UI/Web/src/app/dev-only/dev-only.module.ts b/UI/Web/src/app/dev-only/dev-only.module.ts deleted file mode 100644 index 4ecdc6133..000000000 --- a/UI/Web/src/app/dev-only/dev-only.module.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { NgbAccordionModule, NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; -import { CardsModule } from '../cards/cards.module'; -import { TypeaheadModule } from '../typeahead/typeahead.module'; -import { ThemeTestComponent } from './theme-test/theme-test.component'; -import { SharedModule } from '../shared/shared.module'; -import { PipeModule } from '../pipe/pipe.module'; -import { DevOnlyRoutingModule } from './dev-only-routing.module'; -import { FormsModule } from '@angular/forms'; - -/** - * This module contains components that aren't meant to ship with main code. They are there to test things out. This module may be deleted in future updates. - */ - -@NgModule({ - declarations: [ - ThemeTestComponent - ], - imports: [ - CommonModule, - FormsModule, - - - TypeaheadModule, - CardsModule, - NgbAccordionModule, - NgbNavModule, - - - SharedModule, - PipeModule, - - DevOnlyRoutingModule - ] -}) -export class DevOnlyModule { } diff --git a/UI/Web/src/app/dev-only/theme-test/theme-test.component.html b/UI/Web/src/app/dev-only/theme-test/theme-test.component.html deleted file mode 100644 index 6f44a7886..000000000 --- a/UI/Web/src/app/dev-only/theme-test/theme-test.component.html +++ /dev/null @@ -1,188 +0,0 @@ -

Themes

- - - - - - - -

Buttons

- - - - - - - - -

Toastr

- - - - - - -

Inputs

-

Inputs should always have class="form-control" on them

- - - - - - - - - -

Checkbox

-
- -
- - -
-
- -
- -
- - -
-
- -

Radio

-

Labels should have form-check-label on them and inputs form-check-input

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -

Nav tabs

-

Tabs

- -
- -

Tabs

- -
- -

Tag Badge

-
- Selectable - Clickable - Non Allowed -
- -

Person Badge with Expander

-
- - - - - - -
- -

Switch

-
-
- -
-
- - -
-
-
-
- -

Dropdown/List Group

- - -

Accordion

- - - -

- -

-
- -

This is the body of the accordion...........This is the body of the accordion asdfasdf asThis is the body of the accordion asdfasdf asThis is the body of the accordion asdfasdf asThis is the body of the accordion asdfasdf asThis is the body of the accordion asdfasdf as

-
-
- - - -

- -

-
- -

This is the body of the accordion asdfasdf as - dfas - f asdfasdfasdf asdfasdfaaff asdf - as fd - asfasf asdfasdfafd -

-
-
-
- - -

Cards

- - - diff --git a/UI/Web/src/app/dev-only/theme-test/theme-test.component.scss b/UI/Web/src/app/dev-only/theme-test/theme-test.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/UI/Web/src/app/dev-only/theme-test/theme-test.component.ts b/UI/Web/src/app/dev-only/theme-test/theme-test.component.ts deleted file mode 100644 index be3ab498b..000000000 --- a/UI/Web/src/app/dev-only/theme-test/theme-test.component.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { ToastrService } from 'ngx-toastr'; -import { TagBadgeCursor } from '../../shared/tag-badge/tag-badge.component'; -import { ThemeService } from '../../_services/theme.service'; -import { MangaFormat } from '../../_models/manga-format'; -import { Person, PersonRole } from '../../_models/person'; -import { Series } from '../../_models/series'; -import { NavService } from '../../_services/nav.service'; - -@Component({ - selector: 'app-theme-test', - templateUrl: './theme-test.component.html', - styleUrls: ['./theme-test.component.scss'] -}) -export class ThemeTestComponent implements OnInit { - - tabs: Array<{title: string, fragment: string}> = [ - {title: 'General', fragment: ''}, - {title: 'Users', fragment: 'users'}, - {title: 'Libraries', fragment: 'libraries'}, - {title: 'System', fragment: 'system'}, - {title: 'Changelog', fragment: 'changelog'}, - ]; - active = this.tabs[0]; - - people: Array = [ - {id: 1, name: 'Joe', role: PersonRole.Artist}, - {id: 2, name: 'Joe 2', role: PersonRole.Artist}, - ]; - - seriesNotRead: Series = { - id: 1, - name: 'Test Series', - pages: 0, - pagesRead: 10, - format: MangaFormat.ARCHIVE, - libraryId: 1, - coverImageLocked: false, - created: '', - latestReadDate: '', - localizedName: '', - originalName: '', - sortName: '', - userRating: 0, - userReview: '', - volumes: [], - localizedNameLocked: false, - nameLocked: false, - sortNameLocked: false, - lastChapterAdded: '', - } - - seriesWithProgress: Series = { - id: 1, - name: 'Test Series', - pages: 5, - pagesRead: 10, - format: MangaFormat.ARCHIVE, - libraryId: 1, - coverImageLocked: false, - created: '', - latestReadDate: '', - localizedName: '', - originalName: '', - sortName: '', - userRating: 0, - userReview: '', - volumes: [], - localizedNameLocked: false, - nameLocked: false, - sortNameLocked: false, - lastChapterAdded: '', - } - - get TagBadgeCursor(): typeof TagBadgeCursor { - return TagBadgeCursor; - } - - constructor(public toastr: ToastrService, public navService: NavService, public themeService: ThemeService) { } - - ngOnInit(): void { - } - -} diff --git a/UI/Web/src/app/library-detail/library-detail.component.html b/UI/Web/src/app/library-detail/library-detail.component.html index db9c4d27e..55c3d7d45 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.html +++ b/UI/Web/src/app/library-detail/library-detail.component.html @@ -3,7 +3,7 @@ {{libraryName}} -
{{pagination?.totalItems}} Series
+
{{pagination?.totalItems}} Series
diff --git a/UI/Web/src/app/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/series-detail.component.ts index 2e1a1db29..172101da6 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/series-detail.component.ts @@ -290,6 +290,9 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { case(Action.AddToCollection): this.actionService.addMultipleSeriesToCollectionTag([series], () => this.actionInProgress = false); break; + case (Action.AnalyzeFiles): + this.actionService.analyzeFilesForSeries(series, () => this.actionInProgress = false); + break; default: break; } @@ -372,12 +375,10 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { this.titleService.setTitle('Kavita - ' + this.series.name + ' Details'); this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this)) - .filter(action => action.action !== Action.Edit) - .filter(action => this.actionFactoryService.filterBookmarksForFormat(action, this.series)); + .filter(action => action.action !== Action.Edit); this.volumeActions = this.actionFactoryService.getVolumeActions(this.handleVolumeActionCallback.bind(this)); this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)); - // TODO: Move this to a forkJoin? this.seriesService.getRelatedForSeries(this.seriesId).subscribe((relations: RelatedSeries) => { this.relations = [ ...relations.prequels.map(item => this.createRelatedSeries(item, RelationKind.Prequel)), diff --git a/UI/Web/src/app/series-detail/series-metadata-detail/series-metadata-detail.component.html b/UI/Web/src/app/series-detail/series-metadata-detail/series-metadata-detail.component.html index 07bf0c48e..27f4fada6 100644 --- a/UI/Web/src/app/series-detail/series-metadata-detail/series-metadata-detail.component.html +++ b/UI/Web/src/app/series-detail/series-metadata-detail/series-metadata-detail.component.html @@ -3,22 +3,85 @@ -
- {{metadataService.getAgeRating(this.seriesMetadata.ageRating) | async}} +
+ +
+ + {{metadataService.getAgeRating(this.seriesMetadata.ageRating) | async}} + +
+
+
- {{seriesMetadata.releaseYear}} - {{seriesMetadata.language}} - - {{seriesMetadata.publicationStatus | publicationStatus}} + +
+ + {{seriesMetadata.releaseYear}} + +
+
+
- - {{utilityService.mangaFormat(series.format)}} - - - Last Read: {{series.latestReadDate | date:'shortDate'}} - + +
+ + {{seriesMetadata.language | defaultValue:'en' | languageName | async}} + +
+
+
+ + +
+ + {{seriesMetadata.publicationStatus | publicationStatus}} + +
+
+
+ + +
+ + {{utilityService.mangaFormat(series.format)}} + +
+
+
+ + +
+ + {{series.latestReadDate | date:'shortDate'}} + +
+
+
+ + +
+ + {{series.pages}} Pages + +
+
+ + +
+ + {{minHoursToRead}}{{maxHoursToRead !== minHoursToRead ? ('-' + maxHoursToRead) : ''}} Hours + +
+
+ + +
+
+ + {{series.wordCount | compactNumber}} Words + +
+
@@ -92,11 +155,6 @@
- -
@@ -213,4 +271,13 @@
+
+ + \ No newline at end of file diff --git a/UI/Web/src/app/series-detail/series-metadata-detail/series-metadata-detail.component.ts b/UI/Web/src/app/series-detail/series-metadata-detail/series-metadata-detail.component.ts index 1993be51a..8cbfa392e 100644 --- a/UI/Web/src/app/series-detail/series-metadata-detail/series-metadata-detail.component.ts +++ b/UI/Web/src/app/series-detail/series-metadata-detail/series-metadata-detail.component.ts @@ -9,6 +9,12 @@ import { Series } from '../../_models/series'; import { SeriesMetadata } from '../../_models/series-metadata'; import { MetadataService } from '../../_services/metadata.service'; +const MAX_WORDS_PER_HOUR = 30_000; +const MIN_WORDS_PER_HOUR = 10_260; +const MAX_PAGES_PER_MINUTE = 2.75; +const MIN_PAGES_PER_MINUTE = 3.33; + + @Component({ selector: 'app-series-metadata-detail', templateUrl: './series-metadata-detail.component.html', @@ -26,6 +32,9 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges { isCollapsed: boolean = true; hasExtendedProperites: boolean = false; + minHoursToRead: number = 1; + maxHoursToRead: number = 1; + /** * Html representation of Series Summary */ @@ -58,8 +67,19 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges { if (this.seriesMetadata !== null) { this.seriesSummary = (this.seriesMetadata.summary === null ? '' : this.seriesMetadata.summary).replace(/\n/g, '
'); + + + + } + if (this.series !== null && this.series.wordCount > 0) { + if (this.series.format === MangaFormat.EPUB) { + this.minHoursToRead = parseInt(Math.round(this.series.wordCount / MAX_WORDS_PER_HOUR) + '', 10); + this.maxHoursToRead = parseInt(Math.round(this.series.wordCount / MIN_WORDS_PER_HOUR) + '', 10); + } else if (this.series.format === MangaFormat.IMAGE || this.series.format === MangaFormat.ARCHIVE) { + this.minHoursToRead = parseInt(Math.round((this.series.wordCount * MAX_PAGES_PER_MINUTE) / 60) + '', 10); + this.maxHoursToRead = parseInt(Math.round((this.series.wordCount * MIN_PAGES_PER_MINUTE) / 60) + '', 10); + } } - } ngOnInit(): void { diff --git a/UI/Web/src/app/shared/icon-and-title/icon-and-title.component.html b/UI/Web/src/app/shared/icon-and-title/icon-and-title.component.html new file mode 100644 index 000000000..183d5fb10 --- /dev/null +++ b/UI/Web/src/app/shared/icon-and-title/icon-and-title.component.html @@ -0,0 +1,8 @@ +
+ + +
+ +
+
\ No newline at end of file diff --git a/UI/Web/src/app/shared/icon-and-title/icon-and-title.component.scss b/UI/Web/src/app/shared/icon-and-title/icon-and-title.component.scss new file mode 100644 index 000000000..7f7d1ce16 --- /dev/null +++ b/UI/Web/src/app/shared/icon-and-title/icon-and-title.component.scss @@ -0,0 +1,9 @@ +.icon-and-title { + flex-direction: column; + min-width: 60px; +} + +.icon { + width: 20px; + height: 20px; +} \ No newline at end of file diff --git a/UI/Web/src/app/shared/icon-and-title/icon-and-title.component.ts b/UI/Web/src/app/shared/icon-and-title/icon-and-title.component.ts new file mode 100644 index 000000000..fe5be4b32 --- /dev/null +++ b/UI/Web/src/app/shared/icon-and-title/icon-and-title.component.ts @@ -0,0 +1,32 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; + +@Component({ + selector: 'app-icon-and-title', + templateUrl: './icon-and-title.component.html', + styleUrls: ['./icon-and-title.component.scss'] +}) +export class IconAndTitleComponent implements OnInit { + /** + * If the component is clickable and should emit click events + */ + @Input() clickable: boolean = true; + @Input() title: string = ''; + /** + * Font classes used to display font + */ + @Input() fontClasses: string = ''; + + @Output() click: EventEmitter = new EventEmitter(); + + + + constructor() { } + + ngOnInit(): void { + } + + handleClick(event: MouseEvent) { + if (this.clickable) this.click.emit(event); + } + +} diff --git a/UI/Web/src/app/shared/shared.module.ts b/UI/Web/src/app/shared/shared.module.ts index 70d4283b6..53be1c743 100644 --- a/UI/Web/src/app/shared/shared.module.ts +++ b/UI/Web/src/app/shared/shared.module.ts @@ -17,6 +17,7 @@ import { PersonBadgeComponent } from './person-badge/person-badge.component'; import { BadgeExpanderComponent } from './badge-expander/badge-expander.component'; import { ImageComponent } from './image/image.component'; import { PipeModule } from '../pipe/pipe.module'; +import { IconAndTitleComponent } from './icon-and-title/icon-and-title.component'; @NgModule({ declarations: [ @@ -32,6 +33,7 @@ import { PipeModule } from '../pipe/pipe.module'; PersonBadgeComponent, BadgeExpanderComponent, ImageComponent, + IconAndTitleComponent, ], imports: [ CommonModule, @@ -55,6 +57,8 @@ import { PipeModule } from '../pipe/pipe.module'; PersonBadgeComponent, // Used Series Detail BadgeExpanderComponent, // Used Series Detail/Metadata + + IconAndTitleComponent // Used in Series Detail/Metadata ], }) diff --git a/UI/Web/src/app/sidenav/side-nav/side-nav.component.ts b/UI/Web/src/app/sidenav/side-nav/side-nav.component.ts index 7a5a33fe0..eccd7ad50 100644 --- a/UI/Web/src/app/sidenav/side-nav/side-nav.component.ts +++ b/UI/Web/src/app/sidenav/side-nav/side-nav.component.ts @@ -84,6 +84,9 @@ export class SideNavComponent implements OnInit, OnDestroy { case(Action.RefreshMetadata): this.actionService.refreshMetadata(library); break; + case (Action.AnalyzeFiles): + this.actionService.analyzeFiles(library); + break; default: break; } diff --git a/UI/Web/src/theme/utilities/_global.scss b/UI/Web/src/theme/utilities/_global.scss index 1121ad658..55ad54f39 100644 --- a/UI/Web/src/theme/utilities/_global.scss +++ b/UI/Web/src/theme/utilities/_global.scss @@ -25,3 +25,7 @@ hr { .text-muted { color: var(--text-muted-color) !important; } + +.subtitle-with-actionables { + margin-left: 32px; +} \ No newline at end of file