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.
This commit is contained in:
Joseph Milazzo 2022-01-08 06:41:47 -08:00 committed by GitHub
parent c215d5b7a8
commit 0be0e294aa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1671 additions and 90 deletions

View File

@ -1,10 +1,13 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.IO.Abstractions;
using System.IO.Abstractions.TestingHelpers; using System.IO.Abstractions.TestingHelpers;
using API.Entities; using API.Entities;
using API.Helpers; using API.Helpers;
using API.Services; using API.Services;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit; using Xunit;
namespace API.Tests.Helpers; namespace API.Tests.Helpers;
@ -73,6 +76,19 @@ public class CacheHelperTests
false, false)); 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] [Fact]
public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetLocked() public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetLocked()
{ {

View File

@ -79,7 +79,7 @@ namespace API.Tests.Parser
[InlineData("src: local(\"/fonts/OpenSans-Regular-webfont.woff2\")", new [] {"src: local(\"", "/fonts/OpenSans-Regular-webfont.woff2", "\")"})] [InlineData("src: local(\"/fonts/OpenSans-Regular-webfont.woff2\")", new [] {"src: local(\"", "/fonts/OpenSans-Regular-webfont.woff2", "\")"})]
public void FontCssCorrectlySeparates(string input, string[] expected) 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());
} }

View File

@ -52,6 +52,39 @@ namespace API.Tests.Services
Assert.Equal(28, files.Count); 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<ILogger<DirectoryService>>(), fileSystem);
var files = new List<string>();
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] [Fact]
public void TraverseTreeParallelForEach_DontCountExcludedDirectories_ShouldBe28() public void TraverseTreeParallelForEach_DontCountExcludedDirectories_ShouldBe28()
{ {

View File

@ -23,7 +23,7 @@ namespace API.Comparators
_isAscending = inAscendingOrder; _isAscending = inAscendingOrder;
} }
int IComparer<string>.Compare(string x, string y) int IComparer<string>.Compare(string? x, string? y)
{ {
if (x == y) return 0; if (x == y) return 0;

View File

@ -62,6 +62,14 @@ namespace API.DTOs
/// </summary> /// </summary>
/// <remarks>Metadata field</remarks> /// <remarks>Metadata field</remarks>
public string TitleName { get; set; } public string TitleName { get; set; }
/// <summary>
/// Summary for the Chapter/Issue
/// </summary>
public string Summary { get; set; }
/// <summary>
/// Language for the Chapter/Issue
/// </summary>
public string Language { get; set; }
public ICollection<PersonDto> Writers { get; set; } = new List<PersonDto>(); public ICollection<PersonDto> Writers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Penciller { get; set; } = new List<PersonDto>(); public ICollection<PersonDto> Penciller { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Inker { get; set; } = new List<PersonDto>(); public ICollection<PersonDto> Inker { get; set; } = new List<PersonDto>();

View File

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using API.Data.Metadata; using API.Data.Metadata;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
@ -118,7 +119,7 @@ namespace API.Data
FilePath = filePath, FilePath = filePath,
Format = format, Format = format,
Pages = pages, Pages = pages,
LastModified = DateTime.Now //File.GetLastWriteTime(filePath) LastModified = File.GetLastWriteTime(filePath) // NOTE: Changed this from DateTime.Now
}; };
} }

View File

@ -85,5 +85,6 @@ namespace API.Data.Metadata
.SingleOrDefault(t => t.ToDescription().ToUpperInvariant().Equals(value.ToUpperInvariant()), Entities.Enums.AgeRating.Unknown); .SingleOrDefault(t => t.ToDescription().ToUpperInvariant().Equals(value.ToUpperInvariant()), Entities.Enums.AgeRating.Unknown);
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -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<string>(
name: "Language",
table: "Chapter",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Summary",
table: "Chapter",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "ChapterGenre",
columns: table => new
{
ChaptersId = table.Column<int>(type: "INTEGER", nullable: false),
GenresId = table.Column<int>(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<int>(
name: "GenreId",
table: "Chapter",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
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");
}
}
}

View File

@ -186,9 +186,6 @@ namespace API.Data.Migrations
b.Property<bool>("BookReaderTapToPaginate") b.Property<bool>("BookReaderTapToPaginate")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int>("FullscreenMode")
.HasColumnType("INTEGER");
b.Property<int>("PageSplitOption") b.Property<int>("PageSplitOption")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -311,12 +308,12 @@ namespace API.Data.Migrations
b.Property<DateTime>("Created") b.Property<DateTime>("Created")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int?>("GenreId")
.HasColumnType("INTEGER");
b.Property<bool>("IsSpecial") b.Property<bool>("IsSpecial")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("Language")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified") b.Property<DateTime>("LastModified")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -332,6 +329,9 @@ namespace API.Data.Migrations
b.Property<DateTime>("ReleaseDate") b.Property<DateTime>("ReleaseDate")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<string>("Title") b.Property<string>("Title")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -343,8 +343,6 @@ namespace API.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("GenreId");
b.HasIndex("VolumeId"); b.HasIndex("VolumeId");
b.ToTable("Chapter"); b.ToTable("Chapter");
@ -749,6 +747,21 @@ namespace API.Data.Migrations
b.ToTable("AppUserLibrary"); b.ToTable("AppUserLibrary");
}); });
modelBuilder.Entity("ChapterGenre", b =>
{
b.Property<int>("ChaptersId")
.HasColumnType("INTEGER");
b.Property<int>("GenresId")
.HasColumnType("INTEGER");
b.HasKey("ChaptersId", "GenresId");
b.HasIndex("GenresId");
b.ToTable("ChapterGenre");
});
modelBuilder.Entity("ChapterPerson", b => modelBuilder.Entity("ChapterPerson", b =>
{ {
b.Property<int>("ChapterMetadatasId") b.Property<int>("ChapterMetadatasId")
@ -1000,10 +1013,6 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.Chapter", b => modelBuilder.Entity("API.Entities.Chapter", b =>
{ {
b.HasOne("API.Entities.Genre", null)
.WithMany("Chapters")
.HasForeignKey("GenreId");
b.HasOne("API.Entities.Volume", "Volume") b.HasOne("API.Entities.Volume", "Volume")
.WithMany("Chapters") .WithMany("Chapters")
.HasForeignKey("VolumeId") .HasForeignKey("VolumeId")
@ -1129,6 +1138,21 @@ namespace API.Data.Migrations
.IsRequired(); .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 => modelBuilder.Entity("ChapterPerson", b =>
{ {
b.HasOne("API.Entities.Chapter", null) b.HasOne("API.Entities.Chapter", null)
@ -1280,11 +1304,6 @@ namespace API.Data.Migrations
b.Navigation("Files"); b.Navigation("Files");
}); });
modelBuilder.Entity("API.Entities.Genre", b =>
{
b.Navigation("Chapters");
});
modelBuilder.Entity("API.Entities.Library", b => modelBuilder.Entity("API.Entities.Library", b =>
{ {
b.Navigation("Folders"); b.Navigation("Folders");

View File

@ -156,6 +156,11 @@ public class SeriesRepository : ISeriesRepository
.Include(s => s.Volumes) .Include(s => s.Volumes)
.ThenInclude(v => v.Chapters) .ThenInclude(v => v.Chapters)
.ThenInclude(c => c.Genres)
.Include(s => s.Volumes)
.ThenInclude(v => v.Chapters)
.ThenInclude(c => c.Tags)
.Include(s => s.Volumes) .Include(s => s.Volumes)
.ThenInclude(v => v.Chapters) .ThenInclude(v => v.Chapters)
@ -186,7 +191,12 @@ public class SeriesRepository : ISeriesRepository
.Include(s => s.Volumes) .Include(s => s.Volumes)
.ThenInclude(v => v.Chapters) .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) .Include(s => s.Metadata)
.ThenInclude(m => m.Tags) .ThenInclude(m => m.Tags)
@ -590,6 +600,7 @@ public class SeriesRepository : ISeriesRepository
.Include(m => m.People) .Include(m => m.People)
.AsNoTracking() .AsNoTracking()
.ProjectTo<SeriesMetadataDto>(_mapper.ConfigurationProvider) .ProjectTo<SeriesMetadataDto>(_mapper.ConfigurationProvider)
.AsSplitQuery()
.SingleOrDefaultAsync(); .SingleOrDefaultAsync();
if (metadataDto != null) if (metadataDto != null)

View File

@ -12,10 +12,11 @@ namespace API.Entities
public int VolumeId { get; set; } public int VolumeId { get; set; }
public int SeriesId { get; set; } public int SeriesId { get; set; }
public int ChapterId { get; set; } public int ChapterId { get; set; }
/// <summary> /// <summary>
/// Filename in the Bookmark Directory /// Filename in the Bookmark Directory
/// </summary> /// </summary>
public string FileName { get; set; } public string FileName { get; set; } = string.Empty;
// Relationships // Relationships

View File

@ -56,12 +56,24 @@ namespace API.Entities
/// Date which chapter was released /// Date which chapter was released
/// </summary> /// </summary>
public DateTime ReleaseDate { get; set; } public DateTime ReleaseDate { get; set; }
/// <summary>
/// Summary for the Chapter/Issue
/// </summary>
public string Summary { get; set; }
/// <summary>
/// Language for the Chapter/Issue
/// </summary>
public string Language { get; set; }
/// <summary> /// <summary>
/// All people attached at a Chapter level. Usually Comics will have different people per issue. /// All people attached at a Chapter level. Usually Comics will have different people per issue.
/// </summary> /// </summary>
public ICollection<Person> People { get; set; } = new List<Person>(); public ICollection<Person> People { get; set; } = new List<Person>();
/// <summary>
/// Genres for the Chapter
/// </summary>
public ICollection<Genre> Genres { get; set; } = new List<Genre>();
public ICollection<Tag> Tags { get; set; } = new List<Tag>(); public ICollection<Tag> Tags { get; set; } = new List<Tag>();

View File

@ -23,6 +23,7 @@ namespace API.Entities
/// <summary> /// <summary>
/// Last time underlying file was modified /// Last time underlying file was modified
/// </summary> /// </summary>
/// <remarks>This gets updated anytime the file is scanned</remarks>
public DateTime LastModified { get; set; } public DateTime LastModified { get; set; }
@ -32,11 +33,10 @@ namespace API.Entities
/// <summary> /// <summary>
/// Updates the Last Modified time of the underlying file /// Updates the Last Modified time of the underlying file to the LastWriteTime
/// </summary> /// </summary>
public void UpdateLastModified() public void UpdateLastModified()
{ {
// Should this be DateTime.Now ?
LastModified = File.GetLastWriteTime(FilePath); LastModified = File.GetLastWriteTime(FilePath);
} }
} }

View File

@ -43,7 +43,7 @@ public class CacheHelper : ICacheHelper
if (isCoverLocked && fileExists) return false; if (isCoverLocked && fileExists) return false;
if (forceUpdate) return true; if (forceUpdate) return true;
if (firstFile == null) return true; if (firstFile == null) return true;
return (_fileService.HasFileBeenModifiedSince(coverPath, chapterCreated)) || !fileExists; return (_fileService.HasFileBeenModifiedSince(firstFile.FilePath, firstFile.LastModified)) || !fileExists;
} }
/// <summary> /// <summary>

View File

@ -28,7 +28,7 @@ public interface IMetadataService
/// <param name="forceUpdate"></param> /// <param name="forceUpdate"></param>
Task RefreshMetadata(int libraryId, bool forceUpdate = false); Task RefreshMetadata(int libraryId, bool forceUpdate = false);
/// <summary> /// <summary>
/// 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
/// </summary> /// </summary>
/// <param name="libraryId"></param> /// <param name="libraryId"></param>
/// <param name="seriesId"></param> /// <param name="seriesId"></param>
@ -76,18 +76,17 @@ public class MetadataService : IMetadataService
return true; return true;
} }
private void UpdateChapterMetadata(Chapter chapter, ICollection<Person> allPeople, ICollection<Tag> allTags, bool forceUpdate) private void UpdateChapterMetadata(Chapter chapter, ICollection<Person> allPeople, ICollection<Tag> allTags, ICollection<Genre> allGenres, bool forceUpdate)
{ {
var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault();
if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) return; if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) return;
UpdateChapterFromComicInfo(chapter, allPeople, allTags, firstFile); UpdateChapterFromComicInfo(chapter, allPeople, allTags, allGenres, firstFile);
firstFile.UpdateLastModified(); firstFile.UpdateLastModified();
} }
private void UpdateChapterFromComicInfo(Chapter chapter, ICollection<Person> allPeople, ICollection<Tag> allTags, MangaFile firstFile) private void UpdateChapterFromComicInfo(Chapter chapter, ICollection<Person> allPeople, ICollection<Tag> allTags, ICollection<Genre> 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); var comicInfo = _readingItemService.GetComicInfo(firstFile.FilePath, firstFile.Format);
if (comicInfo == null) return; if (comicInfo == null) return;
@ -196,6 +195,14 @@ public class MetadataService : IMetadataService
PersonHelper.UpdatePeople(allPeople, people, PersonRole.Publisher, PersonHelper.UpdatePeople(allPeople, people, PersonRole.Publisher,
person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); 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; series.CoverImage = firstCover?.CoverImage ?? coverImage;
} }
private void UpdateSeriesMetadata(Series series, ICollection<Person> allPeople, ICollection<Genre> allGenres, ICollection<Tag> allTags, bool forceUpdate) private static void UpdateSeriesMetadata(Series series, ICollection<Person> allPeople, ICollection<Genre> allGenres, ICollection<Tag> allTags, bool forceUpdate)
{ {
var isBook = series.Library.Type == LibraryType.Book; var isBook = series.Library.Type == LibraryType.Book;
var firstVolume = series.Volumes.OrderBy(c => c.Number, new ChapterSortComparer()).FirstWithChapters(isBook); var firstVolume = series.Volumes.OrderBy(c => c.Number, new ChapterSortComparer()).FirstWithChapters(isBook);
var firstChapter = firstVolume?.Chapters.GetFirstChapterWithFiles(); var firstChapter = firstVolume?.Chapters.GetFirstChapterWithFiles();
var firstFile = firstChapter?.Files.FirstOrDefault(); 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; 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, PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Writer).Select(p => p.Name), PersonRole.Writer,
person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); 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.UpdateTag(allTags, chapter.Tags.Select(t => t.Title), false, (tag, added) =>
TagHelper.AddTagIfNotExists(series.Metadata.Tags, tag)); 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 var people = chapters.SelectMany(c => c.People).ToList();
.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();
PersonHelper.KeepOnlySamePeopleBetweenLists(series.Metadata.People, PersonHelper.KeepOnlySamePeopleBetweenLists(series.Metadata.People,
people, person => series.Metadata.People.Remove(person)); 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));
} }
/// <summary> /// <summary>
@ -352,20 +351,34 @@ public class MetadataService : IMetadataService
_logger.LogDebug("[MetadataService] Processing series {SeriesName}", series.OriginalName); _logger.LogDebug("[MetadataService] Processing series {SeriesName}", series.OriginalName);
try try
{ {
var volumeUpdated = false; var volumeIndex = 0;
var firstVolumeUpdated = false;
foreach (var volume in series.Volumes) 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) foreach (var chapter in volume.Chapters)
{ {
chapterUpdated = UpdateChapterCoverImage(chapter, forceUpdate); var chapterUpdated = UpdateChapterCoverImage(chapter, forceUpdate);
UpdateChapterMetadata(chapter, allPeople, allTags, forceUpdate || chapterUpdated); // 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); UpdateSeriesMetadata(series, allPeople, allGenres, allTags, forceUpdate);
} }
catch (Exception ex) catch (Exception ex)

View File

@ -641,6 +641,7 @@ public class ScannerService : IScannerService
existingFile.Format = info.Format; existingFile.Format = info.Format;
if (!_fileService.HasFileBeenModifiedSince(existingFile.FilePath, existingFile.LastModified) && existingFile.Pages != 0) return; if (!_fileService.HasFileBeenModifiedSince(existingFile.FilePath, existingFile.LastModified) && existingFile.Pages != 0) return;
existingFile.Pages = _readingItemService.GetNumberOfPages(info.FullFilePath, info.Format); existingFile.Pages = _readingItemService.GetNumberOfPages(info.FullFilePath, info.Format);
//existingFile.UpdateLastModified(); // We skip updating DB here so that metadata refresh can do it
} }
else else
{ {

View File

@ -3,11 +3,11 @@
<div class="card w-100 mb-2" style="width: 18rem;"> <div class="card w-100 mb-2" style="width: 18rem;">
<div class="card-body"> <div class="card-body">
<h4 class="card-title">{{update.updateTitle}}&nbsp; <h4 class="card-title">{{update.updateTitle}}&nbsp;
<span class="badge badge-secondary" *ngIf="update.updateVersion === update.currentVersion">Installed</span> <span class="badge badge-secondary" *ngIf="update.updateVersion === installedVersion">Installed</span>
<span class="badge badge-secondary" *ngIf="update.updateVersion > update.currentVersion">Available</span> <span class="badge badge-secondary" *ngIf="update.updateVersion > installedVersion">Available</span>
</h4> </h4>
<h6 class="card-subtitle mb-2 text-muted">Published: {{update.publishDate | date: 'short'}}</h6> <h6 class="card-subtitle mb-2 text-muted">Published: {{update.publishDate | date: 'short'}}</h6>
<pre class="card-text update-body" [innerHtml]="update.updateBody | safeHtml"></pre> <pre class="card-text update-body" [innerHtml]="update.updateBody | safeHtml"></pre>
<a *ngIf="!update.isDocker" href="{{update.updateUrl}}" class="btn btn-{{indx === 0 ? 'primary' : 'secondary'}} float-right" target="_blank">Download</a> <a *ngIf="!update.isDocker" href="{{update.updateUrl}}" class="btn btn-{{indx === 0 ? 'primary' : 'secondary'}} float-right" target="_blank">Download</a>
</div> </div>

View File

@ -11,13 +11,26 @@ export class ChangelogComponent implements OnInit {
updates: Array<UpdateVersionEvent> = []; updates: Array<UpdateVersionEvent> = [];
isLoading: boolean = true; isLoading: boolean = true;
installedVersion: string = '';
constructor(private serverService: ServerService) { } constructor(private serverService: ServerService) { }
ngOnInit(): void { ngOnInit(): void {
this.serverService.getChangelog().subscribe(updates => {
this.updates = updates; this.serverService.getServerInfo().subscribe(info => {
this.isLoading = false; 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;
}
});
}); });
} }
} }

View File

@ -38,11 +38,13 @@
</div> </div>
<ng-template #filterSection> <ng-template #filterSection>
<ng-template #globalFilterTooltip>This is library agnostic</ng-template>
<div class="filter-section mx-auto pb-3"> <div class="filter-section mx-auto pb-3">
<div class="row justify-content-center no-gutters"> <div class="row justify-content-center no-gutters">
<div class="col-md-2 mr-3" *ngIf="!filterSettings.formatDisabled"> <div class="col-md-2 mr-3" *ngIf="!filterSettings.formatDisabled">
<div class="form-group"> <div class="form-group">
<label for="format">Format</label> <label for="format">Format</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="globalFilterTooltip" role="button" tabindex="0"></i>
<span class="sr-only" id="filter-global-format-help"><ng-container [ngTemplateOutlet]="globalFilterTooltip"></ng-container></span>
<app-typeahead (selectedData)="updateFormatFilters($event)" [settings]="formatSettings" [reset]="resetTypeaheads"> <app-typeahead (selectedData)="updateFormatFilters($event)" [settings]="formatSettings" [reset]="resetTypeaheads">
<ng-template #badgeItem let-item let-position="idx"> <ng-template #badgeItem let-item let-position="idx">
{{item.title}} {{item.title}}
@ -70,7 +72,8 @@
<div class="col-md-2 mr-3" *ngIf="!filterSettings.collectionDisabled"> <div class="col-md-2 mr-3" *ngIf="!filterSettings.collectionDisabled">
<div class="form-group"> <div class="form-group">
<label for="collections">Collections</label> <label for="collections">Collections</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="globalFilterTooltip" role="button" tabindex="0"></i>
<span class="sr-only" id="filter-global-collections-help"><ng-container [ngTemplateOutlet]="globalFilterTooltip"></ng-container></span>
<app-typeahead (selectedData)="updateCollectionFilters($event)" [settings]="collectionSettings" [reset]="resetTypeaheads"> <app-typeahead (selectedData)="updateCollectionFilters($event)" [settings]="collectionSettings" [reset]="resetTypeaheads">
<ng-template #badgeItem let-item let-position="idx"> <ng-template #badgeItem let-item let-position="idx">
{{item.title}} {{item.title}}

View File

@ -84,7 +84,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
libraries: Array<FilterItem<Library>> = []; libraries: Array<FilterItem<Library>> = [];
genres: Array<FilterItem<Genre>> = []; genres: Array<FilterItem<Genre>> = [];
persons: Array<FilterItem<Person>> = []; persons: Array<FilterItem<Person>> = [];
collectionTags: Array<FilterItem<CollectionTag>> = []; //collectionTags: Array<FilterItem<CollectionTag>> = [];
readProgressGroup!: FormGroup; readProgressGroup!: FormGroup;
sortGroup!: FormGroup; sortGroup!: FormGroup;
@ -329,9 +329,11 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
return options.filter(m => m.title.toLowerCase() === f); return options.filter(m => m.title.toLowerCase() === f);
} }
if (this.filterSettings.presetCollectionId > 0) { if (this.filterSettings.presetCollectionId > 0) {
this.collectionSettings.savedData = this.collectionTags.filter(item => item.value.id === this.filterSettings.presetCollectionId); this.collectionSettings.fetchFn('').subscribe(tags => {
this.filter.collectionTags = this.collectionSettings.savedData.map(item => item.value.id); this.collectionSettings.savedData = tags.filter(item => item.value.id === this.filterSettings.presetCollectionId);
this.resetTypeaheads.next(true); this.filter.collectionTags = this.collectionSettings.savedData.map(item => item.value.id);
this.resetTypeaheads.next(true);
});
} }
} }