Ability to turn off Metadata Parsing (#3872)

This commit is contained in:
Joe Milazzo 2025-06-23 18:57:14 -05:00 committed by GitHub
parent fa8d778c8d
commit 36aa5f5c85
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
63 changed files with 4257 additions and 186 deletions

View File

@ -10,8 +10,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.14.0" />
<PackageReference Include="BenchmarkDotNet" Version="0.15.1" />
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.15.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
</ItemGroup>

View File

@ -6,13 +6,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.6" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.14" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.14" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

@ -36,7 +36,7 @@ public class ComicVineParserTests
public void Parse_SeriesWithComicInfo()
{
var actual = _parser.Parse("C:/Comics/Birds of Prey (2002)/Birds of Prey 001 (2002).cbz", "C:/Comics/Birds of Prey (2002)/",
RootDirectory, LibraryType.ComicVine, new ComicInfo()
RootDirectory, LibraryType.ComicVine, true, new ComicInfo()
{
Series = "Birds of Prey",
Volume = "2002"
@ -54,7 +54,7 @@ public class ComicVineParserTests
public void Parse_SeriesWithDirectoryNameAsSeriesYear()
{
var actual = _parser.Parse("C:/Comics/Birds of Prey (2002)/Birds of Prey 001 (2002).cbz", "C:/Comics/Birds of Prey (2002)/",
RootDirectory, LibraryType.ComicVine, null);
RootDirectory, LibraryType.ComicVine, true, null);
Assert.NotNull(actual);
Assert.Equal("Birds of Prey (2002)", actual.Series);
@ -69,7 +69,7 @@ public class ComicVineParserTests
public void Parse_SeriesWithADirectoryNameAsSeriesYear()
{
var actual = _parser.Parse("C:/Comics/DC Comics/Birds of Prey (1999)/Birds of Prey 001 (1999).cbz", "C:/Comics/DC Comics/",
RootDirectory, LibraryType.ComicVine, null);
RootDirectory, LibraryType.ComicVine, true, null);
Assert.NotNull(actual);
Assert.Equal("Birds of Prey (1999)", actual.Series);
@ -84,7 +84,7 @@ public class ComicVineParserTests
public void Parse_FallbackToDirectoryNameOnly()
{
var actual = _parser.Parse("C:/Comics/DC Comics/Blood Syndicate/Blood Syndicate 001 (1999).cbz", "C:/Comics/DC Comics/",
RootDirectory, LibraryType.ComicVine, null);
RootDirectory, LibraryType.ComicVine, true, null);
Assert.NotNull(actual);
Assert.Equal("Blood Syndicate", actual.Series);

View File

@ -33,7 +33,7 @@ public class DefaultParserTests
[InlineData("C:/", "C:/Something Random/Mujaki no Rakuen SP01.cbz", "Something Random")]
public void ParseFromFallbackFolders_FallbackShouldParseSeries(string rootDir, string inputPath, string expectedSeries)
{
var actual = _defaultParser.Parse(inputPath, rootDir, rootDir, LibraryType.Manga, null);
var actual = _defaultParser.Parse(inputPath, rootDir, rootDir, LibraryType.Manga, true, null);
if (actual == null)
{
Assert.NotNull(actual);
@ -74,7 +74,7 @@ public class DefaultParserTests
fs.AddFile(inputFile, new MockFileData(""));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fs);
var parser = new BasicParser(ds, new ImageParser(ds));
var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, null);
var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, true, null);
_defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual);
Assert.Equal(expectedParseInfo, actual.Series);
}
@ -90,7 +90,7 @@ public class DefaultParserTests
fs.AddFile(inputFile, new MockFileData(""));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fs);
var parser = new BasicParser(ds, new ImageParser(ds));
var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, null);
var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, true, null);
_defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual);
Assert.Equal(expectedParseInfo, actual.Series);
}
@ -251,7 +251,7 @@ public class DefaultParserTests
foreach (var file in expected.Keys)
{
var expectedInfo = expected[file];
var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Manga, null);
var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Manga, true, null);
if (expectedInfo == null)
{
Assert.Null(actual);
@ -289,7 +289,7 @@ public class DefaultParserTests
Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image,
FullFilePath = filepath, IsSpecial = false
};
var actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Monster #8", "E:/Manga", LibraryType.Manga, null);
var actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Monster #8", "E:/Manga", LibraryType.Manga, true, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@ -315,7 +315,7 @@ public class DefaultParserTests
FullFilePath = filepath, IsSpecial = false
};
actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga",LibraryType.Manga, null);
actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga",LibraryType.Manga, true, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@ -341,7 +341,7 @@ public class DefaultParserTests
FullFilePath = filepath, IsSpecial = false
};
actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga", LibraryType.Manga, null);
actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga", LibraryType.Manga, true, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@ -383,7 +383,7 @@ public class DefaultParserTests
FullFilePath = filepath
};
var actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, null);
var actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, true, null);
Assert.NotNull(actual);
_testOutputHelper.WriteLine($"Validating {filepath}");
@ -412,7 +412,7 @@ public class DefaultParserTests
FullFilePath = filepath
};
actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, null);
actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, true, null);
Assert.NotNull(actual);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expected.Format, actual.Format);
@ -475,7 +475,7 @@ public class DefaultParserTests
foreach (var file in expected.Keys)
{
var expectedInfo = expected[file];
var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Comic, null);
var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Comic, true, null);
if (expectedInfo == null)
{
Assert.Null(actual);

View File

@ -34,7 +34,7 @@ public class ImageParserTests
public void Parse_SeriesWithDirectoryName()
{
var actual = _parser.Parse("C:/Comics/Birds of Prey/Chapter 01/01.jpg", "C:/Comics/Birds of Prey/",
RootDirectory, LibraryType.Image, null);
RootDirectory, LibraryType.Image, true, null);
Assert.NotNull(actual);
Assert.Equal("Birds of Prey", actual.Series);
@ -48,7 +48,7 @@ public class ImageParserTests
public void Parse_SeriesWithNoNestedChapter()
{
var actual = _parser.Parse("C:/Comics/Birds of Prey/Chapter 01 page 01.jpg", "C:/Comics/",
RootDirectory, LibraryType.Image, null);
RootDirectory, LibraryType.Image, true, null);
Assert.NotNull(actual);
Assert.Equal("Birds of Prey", actual.Series);
@ -62,7 +62,7 @@ public class ImageParserTests
public void Parse_SeriesWithLooseImages()
{
var actual = _parser.Parse("C:/Comics/Birds of Prey/page 01.jpg", "C:/Comics/",
RootDirectory, LibraryType.Image, null);
RootDirectory, LibraryType.Image, true, null);
Assert.NotNull(actual);
Assert.Equal("Birds of Prey", actual.Series);

View File

@ -35,7 +35,7 @@ public class PdfParserTests
{
var actual = _parser.Parse("C:/Books/A Dictionary of Japanese Food - Ingredients and Culture/A Dictionary of Japanese Food - Ingredients and Culture.pdf",
"C:/Books/A Dictionary of Japanese Food - Ingredients and Culture/",
RootDirectory, LibraryType.Book, null);
RootDirectory, LibraryType.Book, true, null);
Assert.NotNull(actual);
Assert.Equal("A Dictionary of Japanese Food - Ingredients and Culture", actual.Series);

View File

@ -34,7 +34,7 @@ public class ImageParsingTests
Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image,
FullFilePath = filepath, IsSpecial = false
};
var actual2 = _parser.Parse(filepath, @"E:\Manga\Monster #8", "E:/Manga", LibraryType.Image, null);
var actual2 = _parser.Parse(filepath, @"E:\Manga\Monster #8", "E:/Manga", LibraryType.Image, true, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@ -60,7 +60,7 @@ public class ImageParsingTests
FullFilePath = filepath, IsSpecial = false
};
actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, null);
actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, true, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@ -86,7 +86,7 @@ public class ImageParsingTests
FullFilePath = filepath, IsSpecial = false
};
actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, null);
actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, true, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);

View File

@ -68,10 +68,8 @@ public class MangaParsingTests
[InlineData("Манга Тома 1-4", "1-4")]
[InlineData("Манга Том 1-4", "1-4")]
[InlineData("조선왕조실톡 106화", "106")]
[InlineData("죽음 13회", "13")]
[InlineData("동의보감 13장", "13")]
[InlineData("몰?루 아카이브 7.5권", "7.5")]
[InlineData("주술회전 1.5권", "1.5")]
[InlineData("63권#200", "63")]
[InlineData("시즌34삽화2", "34")]
[InlineData("Accel World Chapter 001 Volume 002", "2")]

View File

@ -137,7 +137,7 @@ public class BookServiceTests
var comicInfo = _bookService.GetComicInfo(filePath);
Assert.NotNull(comicInfo);
var parserInfo = pdfParser.Parse(filePath, testDirectory, ds.GetParentDirectoryName(testDirectory), LibraryType.Book, comicInfo);
var parserInfo = pdfParser.Parse(filePath, testDirectory, ds.GetParentDirectoryName(testDirectory), LibraryType.Book, true, comicInfo);
Assert.NotNull(parserInfo);
Assert.Equal(parserInfo.Title, comicInfo.Title);
Assert.Equal(parserInfo.Series, comicInfo.Title);

View File

@ -50,12 +50,12 @@ internal class MockReadingItemServiceForCacheService : IReadingItemService
throw new System.NotImplementedException();
}
public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type)
public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true)
{
throw new System.NotImplementedException();
}
public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type)
public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true)
{
throw new System.NotImplementedException();
}

View File

@ -42,7 +42,7 @@ public class ExternalMetadataServiceTests : AbstractDbTest
_externalMetadataService = new ExternalMetadataService(UnitOfWork, Substitute.For<ILogger<ExternalMetadataService>>(),
Mapper, Substitute.For<ILicenseService>(), Substitute.For<IScrobblingService>(), Substitute.For<IEventHub>(),
Substitute.For<ICoverDbService>());
Substitute.For<ICoverDbService>(), Substitute.For<IKavitaPlusApiService>());
}
#region Gloabl

View File

@ -58,35 +58,35 @@ public class MockReadingItemService : IReadingItemService
throw new NotImplementedException();
}
public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type)
public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata)
{
if (_comicVineParser.IsApplicable(path, type))
{
return _comicVineParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
return _comicVineParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path));
}
if (_imageParser.IsApplicable(path, type))
{
return _imageParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
return _imageParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path));
}
if (_bookParser.IsApplicable(path, type))
{
return _bookParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
return _bookParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path));
}
if (_pdfParser.IsApplicable(path, type))
{
return _pdfParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
return _pdfParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path));
}
if (_basicParser.IsApplicable(path, type))
{
return _basicParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
return _basicParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path));
}
return null;
}
public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type)
public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata)
{
return Parse(path, rootPath, libraryRoot, type);
return Parse(path, rootPath, libraryRoot, type, enableMetadata);
}
}

View File

@ -483,7 +483,7 @@ public class ScannerServiceTests : AbstractDbTest
var infos = new Dictionary<string, ComicInfo>();
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
library.LibraryExcludePatterns = [new LibraryExcludePattern() {Pattern = "**/Extra/*"}];
library.LibraryExcludePatterns = [new LibraryExcludePattern() { Pattern = "**/Extra/*" }];
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
@ -507,7 +507,7 @@ public class ScannerServiceTests : AbstractDbTest
var infos = new Dictionary<string, ComicInfo>();
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
library.LibraryExcludePatterns = [new LibraryExcludePattern() {Pattern = "**\\Extra\\*"}];
library.LibraryExcludePatterns = [new LibraryExcludePattern() { Pattern = "**\\Extra\\*" }];
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
@ -938,4 +938,38 @@ public class ScannerServiceTests : AbstractDbTest
Assert.True(sortedChapters[1].SortOrder.Is(4f));
Assert.True(sortedChapters[2].SortOrder.Is(5f));
}
[Fact]
public async Task ScanLibrary_MetadataDisabled_NoOverrides()
{
const string testcase = "Series with Localized No Metadata - Manga.json";
// Get the first file and generate a ComicInfo
var infos = new Dictionary<string, ComicInfo>();
infos.Add("Immoral Guild v01.cbz", new ComicInfo()
{
Series = "Immoral Guild",
LocalizedSeries = "Futoku no Guild" // Filename has a capital N and localizedSeries has lowercase
});
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
// Disable metadata
library.EnableMetadata = false;
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
// Validate that there are 2 series
Assert.NotNull(postLib);
Assert.Equal(2, postLib.Series.Count);
Assert.Contains(postLib.Series, x => x.Name == "Immoral Guild");
Assert.Contains(postLib.Series, x => x.Name == "Futoku No Guild");
}
}

View File

@ -0,0 +1,5 @@
[
"Immoral Guild/Immoral Guild v01.cbz",
"Immoral Guild/Immoral Guild v02.cbz",
"Immoral Guild/Futoku No Guild - Vol. 12 Ch. 67 - Take Responsibility.cbz"
]

View File

@ -50,9 +50,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="MailKit" Version="4.12.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="MailKit" Version="4.12.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@ -62,25 +62,25 @@
<PackageReference Include="ExCSS" Version="4.3.0" />
<PackageReference Include="Flurl" Version="4.0.0" />
<PackageReference Include="Flurl.Http" Version="4.0.2" />
<PackageReference Include="Hangfire" Version="1.8.18" />
<PackageReference Include="Hangfire" Version="1.8.20" />
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.1" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.18" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.20" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.6" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
<PackageReference Include="NetVips" Version="3.0.1" />
<PackageReference Include="NetVips.Native" Version="8.16.1" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="NetVips" Version="3.1.0" />
<PackageReference Include="NetVips.Native" Version="8.17.0.1" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
@ -89,16 +89,16 @@
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.39.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.9.0.115408">
<PackageReference Include="SharpCompress" Version="0.40.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.10" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.11.0.117924">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.3" />
<PackageReference Include="System.Drawing.Common" Version="9.0.5" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" />
<PackageReference Include="System.Drawing.Common" Version="9.0.6" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.12.0" />
<PackageReference Include="System.IO.Abstractions" Version="22.0.14" />
<PackageReference Include="VersOne.Epub" Version="3.3.4" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />

View File

@ -623,6 +623,7 @@ public class LibraryController : BaseApiController
library.ManageReadingLists = dto.ManageReadingLists;
library.AllowScrobbling = dto.AllowScrobbling;
library.AllowMetadataMatching = dto.AllowMetadataMatching;
library.EnableMetadata = dto.EnableMetadata;
library.LibraryFileTypes = dto.FileGroupTypes
.Select(t => new LibraryFileTypeGroup() {FileTypeGroup = t, LibraryId = library.Id})
.Distinct()

View File

@ -6,7 +6,7 @@ namespace API.DTOs.KavitaPlus.ExternalMetadata;
/// <summary>
/// Used for matching and fetching metadata on a series
/// </summary>
internal sealed record ExternalMetadataIdsDto
public sealed record ExternalMetadataIdsDto
{
public long? MalId { get; set; }
public int? AniListId { get; set; }

View File

@ -7,7 +7,7 @@ namespace API.DTOs.KavitaPlus.ExternalMetadata;
/// <summary>
/// Represents a request to match some series from Kavita to an external id which K+ uses.
/// </summary>
internal sealed record MatchSeriesRequestDto
public sealed record MatchSeriesRequestDto
{
public required string SeriesName { get; set; }
public ICollection<string> AlternativeNames { get; set; } = [];

View File

@ -6,7 +6,7 @@ using API.DTOs.SeriesDetail;
namespace API.DTOs.KavitaPlus.ExternalMetadata;
internal sealed record SeriesDetailPlusApiDto
public sealed record SeriesDetailPlusApiDto
{
public IEnumerable<MediaRecommendationDto> Recommendations { get; set; }
public IEnumerable<UserReviewDto> Reviews { get; set; }

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using API.DTOs.SeriesDetail;
namespace API.DTOs.KavitaPlus.Metadata;
#nullable enable
/// <summary>
/// Information about an individual issue/chapter/book from Kavita+

View File

@ -66,4 +66,8 @@ public sealed record LibraryDto
/// <remarks>This does not exclude the library from being linked to wrt Series Relationships</remarks>
/// <remarks>Requires a valid LicenseKey</remarks>
public bool AllowMetadataMatching { get; set; } = true;
/// <summary>
/// Allow Kavita to read metadata (ComicInfo.xml, Epub, PDF)
/// </summary>
public bool EnableMetadata { get; set; } = true;
}

View File

@ -28,6 +28,8 @@ public sealed record UpdateLibraryDto
public bool AllowScrobbling { get; init; }
[Required]
public bool AllowMetadataMatching { get; init; }
[Required]
public bool EnableMetadata { get; init; }
/// <summary>
/// What types of files to allow the scanner to pickup
/// </summary>

View File

@ -147,6 +147,9 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
builder.Entity<Library>()
.Property(b => b.AllowMetadataMatching)
.HasDefaultValue(true);
builder.Entity<Library>()
.Property(b => b.EnableMetadata)
.HasDefaultValue(true);
builder.Entity<Chapter>()
.Property(b => b.WebLinks)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class EnableMetadataLibrary : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "EnableMetadata",
table: "Library",
type: "INTEGER",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EnableMetadata",
table: "Library");
}
}
}

View File

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -1296,6 +1296,11 @@ namespace API.Data.Migrations
b.Property<DateTime>("CreatedUtc")
.HasColumnType("TEXT");
b.Property<bool>("EnableMetadata")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("FolderWatching")
.HasColumnType("INTEGER");

View File

@ -48,6 +48,10 @@ public class Library : IEntityDate, IHasCoverImage
/// <remarks>This does not exclude the library from being linked to wrt Series Relationships</remarks>
/// <remarks>Requires a valid LicenseKey</remarks>
public bool AllowMetadataMatching { get; set; } = true;
/// <summary>
/// Should Kavita read metadata files from the library
/// </summary>
public bool EnableMetadata { get; set; } = true;
public DateTime Created { get; set; }

View File

@ -110,6 +110,12 @@ public class LibraryBuilder : IEntityBuilder<Library>
return this;
}
public LibraryBuilder WithEnableMetadata(bool enable)
{
_library.EnableMetadata = enable;
return this;
}
public LibraryBuilder WithAllowScrobbling(bool allowScrobbling)
{
_library.AllowScrobbling = allowScrobbling;

View File

@ -67,6 +67,7 @@ public class ExternalMetadataService : IExternalMetadataService
private readonly IScrobblingService _scrobblingService;
private readonly IEventHub _eventHub;
private readonly ICoverDbService _coverDbService;
private readonly IKavitaPlusApiService _kavitaPlusApiService;
private readonly TimeSpan _externalSeriesMetadataCache = TimeSpan.FromDays(30);
public static readonly HashSet<LibraryType> NonEligibleLibraryTypes =
[LibraryType.Comic, LibraryType.Book, LibraryType.Image];
@ -82,7 +83,8 @@ public class ExternalMetadataService : IExternalMetadataService
private static bool IsRomanCharacters(string input) => Regex.IsMatch(input, @"^[\p{IsBasicLatin}\p{IsLatin-1Supplement}]+$");
public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger<ExternalMetadataService> logger, IMapper mapper,
ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService)
ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService,
IKavitaPlusApiService kavitaPlusApiService)
{
_unitOfWork = unitOfWork;
_logger = logger;
@ -91,6 +93,7 @@ public class ExternalMetadataService : IExternalMetadataService
_scrobblingService = scrobblingService;
_eventHub = eventHub;
_coverDbService = coverDbService;
_kavitaPlusApiService = kavitaPlusApiService;
FlurlConfiguration.ConfigureClientForUrl(Configuration.KavitaPlusApiUrl);
}
@ -179,9 +182,7 @@ public class ExternalMetadataService : IExternalMetadataService
_logger.LogDebug("Fetching Kavita+ for MAL Stacks for user {UserName}", user.MalUserName);
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
var result = await ($"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/stacks?username={user.MalUserName}")
.WithKavitaPlusHeaders(license)
.GetJsonAsync<IList<MalStackDto>>();
var result = await _kavitaPlusApiService.GetMalStacks(user.MalUserName, license);
if (result == null)
{
@ -207,7 +208,7 @@ public class ExternalMetadataService : IExternalMetadataService
/// <returns></returns>
public async Task<IList<ExternalSeriesMatchDto>> MatchSeries(MatchSeriesDto dto)
{
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId,
SeriesIncludes.Metadata | SeriesIncludes.ExternalMetadata | SeriesIncludes.Library);
if (series == null) return [];
@ -239,14 +240,9 @@ public class ExternalMetadataService : IExternalMetadataService
MalId = potentialMalId ?? ScrobblingService.GetMalId(series)
};
var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken;
try
{
var results = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/match-series")
.WithKavitaPlusHeaders(license, token)
.PostJsonAsync(matchRequest)
.ReceiveJson<IList<ExternalSeriesMatchDto>>();
var results = await _kavitaPlusApiService.MatchSeries(matchRequest);
// Some summaries can contain multiple <br/>s, we need to ensure it's only 1
foreach (var result in results)
@ -287,9 +283,7 @@ public class ExternalMetadataService : IExternalMetadataService
}
// This is for the Series drawer. We can get this extra information during the initial SeriesDetail call so it's all coming from the DB
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
var details = await GetSeriesDetail(license, aniListId, malId, seriesId);
var details = await GetSeriesDetail(aniListId, malId, seriesId);
return details;
@ -392,6 +386,9 @@ public class ExternalMetadataService : IExternalMetadataService
{
// We can't rethrow because Fix match is done in a background thread and Hangfire will requeue multiple times
_logger.LogInformation(ex, "Rate limit hit for matching {SeriesName} with Kavita+", series.Name);
// Fire SignalR event about this
await _eventHub.SendMessageAsync(MessageFactory.ExternalMatchRateLimitError,
MessageFactory.ExternalMatchRateLimitErrorEvent(series.Id, series.Name));
}
}
@ -442,16 +439,12 @@ public class ExternalMetadataService : IExternalMetadataService
try
{
_logger.LogDebug("Fetching Kavita+ Series Detail data for {SeriesName}", string.IsNullOrEmpty(data.SeriesName) ? data.AniListId : data.SeriesName);
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken;
SeriesDetailPlusApiDto? result = null;
try
{
result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail")
.WithKavitaPlusHeaders(license, token)
.PostJsonAsync(data)
.ReceiveJson<SeriesDetailPlusApiDto>(); // This returns an AniListSeries and Match returns ExternalSeriesDto
// This returns an AniListSeries and Match returns ExternalSeriesDto
result = await _kavitaPlusApiService.GetSeriesDetail(data);
}
catch (FlurlHttpException ex)
{
@ -466,11 +459,7 @@ public class ExternalMetadataService : IExternalMetadataService
_logger.LogDebug("Hit rate limit, will retry in 3 seconds");
await Task.Delay(3000);
result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail")
.WithKavitaPlusHeaders(license, token)
.PostJsonAsync(data)
.ReceiveJson<
SeriesDetailPlusApiDto>();
result = await _kavitaPlusApiService.GetSeriesDetail(data);
}
else if (errorMessage.Contains("Unknown Series"))
{
@ -1777,7 +1766,7 @@ public class ExternalMetadataService : IExternalMetadataService
/// <param name="malId"></param>
/// <param name="seriesId"></param>
/// <returns></returns>
private async Task<ExternalSeriesDetailDto?> GetSeriesDetail(string license, int? aniListId, long? malId, int? seriesId)
private async Task<ExternalSeriesDetailDto?> GetSeriesDetail(int? aniListId, long? malId, int? seriesId)
{
var payload = new ExternalMetadataIdsDto()
{
@ -1809,11 +1798,7 @@ public class ExternalMetadataService : IExternalMetadataService
}
try
{
var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken;
var ret = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids")
.WithKavitaPlusHeaders(license, token)
.PostJsonAsync(payload)
.ReceiveJson<ExternalSeriesDetailDto>();
var ret = await _kavitaPlusApiService.GetSeriesDetailById(payload);
ret.Summary = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(ret.Summary));

View File

@ -1,6 +1,13 @@
#nullable enable
using System.Collections.Generic;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Collection;
using API.DTOs.KavitaPlus.ExternalMetadata;
using API.DTOs.KavitaPlus.Metadata;
using API.DTOs.Metadata.Matching;
using API.DTOs.Scrobbling;
using API.Entities.Enums;
using API.Extensions;
using Flurl.Http;
using Kavita.Common;
@ -17,9 +24,13 @@ public interface IKavitaPlusApiService
Task<bool> HasTokenExpired(string license, string token, ScrobbleProvider provider);
Task<int> GetRateLimit(string license, string token);
Task<ScrobbleResponseDto> PostScrobbleUpdate(ScrobbleDto data, string license);
Task<IList<MalStackDto>> GetMalStacks(string malUsername, string license);
Task<IList<ExternalSeriesMatchDto>> MatchSeries(MatchSeriesRequestDto request);
Task<SeriesDetailPlusApiDto> GetSeriesDetail(PlusSeriesRequestDto request);
Task<ExternalSeriesDetailDto> GetSeriesDetailById(ExternalMetadataIdsDto request);
}
public class KavitaPlusApiService(ILogger<KavitaPlusApiService> logger): IKavitaPlusApiService
public class KavitaPlusApiService(ILogger<KavitaPlusApiService> logger, IUnitOfWork unitOfWork): IKavitaPlusApiService
{
private const string ScrobblingPath = "/api/scrobbling/";
@ -42,6 +53,46 @@ public class KavitaPlusApiService(ILogger<KavitaPlusApiService> logger): IKavita
return await PostAndReceive<ScrobbleResponseDto>(ScrobblingPath + "update", data, license);
}
public async Task<IList<MalStackDto>> GetMalStacks(string malUsername, string license)
{
return await $"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/stacks?username={malUsername}"
.WithKavitaPlusHeaders(license)
.GetJsonAsync<IList<MalStackDto>>();
}
public async Task<IList<ExternalSeriesMatchDto>> MatchSeries(MatchSeriesRequestDto request)
{
var license = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
var token = (await unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken;
return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/match-series")
.WithKavitaPlusHeaders(license, token)
.PostJsonAsync(request)
.ReceiveJson<IList<ExternalSeriesMatchDto>>();
}
public async Task<SeriesDetailPlusApiDto> GetSeriesDetail(PlusSeriesRequestDto request)
{
var license = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
var token = (await unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken;
return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail")
.WithKavitaPlusHeaders(license, token)
.PostJsonAsync(request)
.ReceiveJson<SeriesDetailPlusApiDto>();
}
public async Task<ExternalSeriesDetailDto> GetSeriesDetailById(ExternalMetadataIdsDto request)
{
var license = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
var token = (await unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken;
return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids")
.WithKavitaPlusHeaders(license, token)
.PostJsonAsync(request)
.ReceiveJson<ExternalSeriesDetailDto>();
}
/// <summary>
/// Send a GET request to K+
/// </summary>

View File

@ -12,7 +12,7 @@ public interface IReadingItemService
int GetNumberOfPages(string filePath, MangaFormat format);
string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default);
void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1);
ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type);
ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata);
}
public class ReadingItemService : IReadingItemService
@ -71,11 +71,12 @@ public class ReadingItemService : IReadingItemService
/// <param name="path">Path of a file</param>
/// <param name="rootPath"></param>
/// <param name="type">Library type to determine parsing to perform</param>
public ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type)
/// <param name="enableMetadata">Enable Metadata parsing overriding filename parsing</param>
public ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata)
{
try
{
var info = Parse(path, rootPath, libraryRoot, type);
var info = Parse(path, rootPath, libraryRoot, type, enableMetadata);
if (info == null)
{
_logger.LogError("Unable to parse any meaningful information out of file {FilePath}", path);
@ -174,28 +175,29 @@ public class ReadingItemService : IReadingItemService
/// <param name="path"></param>
/// <param name="rootPath"></param>
/// <param name="type"></param>
/// <param name="enableMetadata"></param>
/// <returns></returns>
private ParserInfo? Parse(string path, string rootPath, string libraryRoot, LibraryType type)
private ParserInfo? Parse(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata)
{
if (_comicVineParser.IsApplicable(path, type))
{
return _comicVineParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
return _comicVineParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path));
}
if (_imageParser.IsApplicable(path, type))
{
return _imageParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
return _imageParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path));
}
if (_bookParser.IsApplicable(path, type))
{
return _bookParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
return _bookParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path));
}
if (_pdfParser.IsApplicable(path, type))
{
return _pdfParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
return _pdfParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path));
}
if (_basicParser.IsApplicable(path, type))
{
return _basicParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
return _basicParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path));
}
return null;

View File

@ -804,7 +804,7 @@ public class ParseScannedFiles
{
// Process files sequentially
result.ParserInfos = files
.Select(file => _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type))
.Select(file => _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type, library.EnableMetadata))
.Where(info => info != null)
.ToList()!;
}
@ -812,7 +812,7 @@ public class ParseScannedFiles
{
// Process files in parallel
var tasks = files.Select(file => Task.Run(() =>
_readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type)));
_readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type, library.EnableMetadata)));
var infos = await Task.WhenAll(tasks);
result.ParserInfos = infos.Where(info => info != null).ToList()!;

View File

@ -12,7 +12,7 @@ namespace API.Services.Tasks.Scanner.Parser;
/// </summary>
public class BasicParser(IDirectoryService directoryService, IDefaultParser imageParser) : DefaultParser(directoryService)
{
public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null)
public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null)
{
var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath);
// TODO: Potential Bug: This will return null, but on Image libraries, if all images, we would want to include this.
@ -20,7 +20,7 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag
if (Parser.IsImage(filePath))
{
return imageParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Image, comicInfo);
return imageParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Image, enableMetadata, comicInfo);
}
var ret = new ParserInfo()
@ -101,7 +101,12 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag
}
// Patch in other information from ComicInfo
UpdateFromComicInfo(ret);
if (enableMetadata)
{
UpdateFromComicInfo(ret);
}
if (ret.Volumes == Parser.LooseLeafVolume && ret.Chapters == Parser.DefaultChapter)
{

View File

@ -5,7 +5,7 @@ namespace API.Services.Tasks.Scanner.Parser;
public class BookParser(IDirectoryService directoryService, IBookService bookService, BasicParser basicParser) : DefaultParser(directoryService)
{
public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo comicInfo = null)
public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo comicInfo = null)
{
var info = bookService.ParseInfo(filePath);
if (info == null) return null;
@ -35,7 +35,7 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer
}
else
{
var info2 = basicParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Book, comicInfo);
var info2 = basicParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Book, enableMetadata, comicInfo);
info.Merge(info2);
if (hasVolumeInSeries && info2 != null && Parser.ParseVolume(info2.Series, type)
.Equals(Parser.LooseLeafVolume))

View File

@ -19,7 +19,7 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser
/// <param name="rootPath"></param>
/// <param name="type"></param>
/// <returns></returns>
public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null)
public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null)
{
if (type != LibraryType.ComicVine) return null;
@ -81,7 +81,10 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser
info.IsSpecial = Parser.IsSpecial(info.Filename, type) || Parser.IsSpecial(info.ComicInfo?.Format, type);
// Patch in other information from ComicInfo
UpdateFromComicInfo(info);
if (enableMetadata)
{
UpdateFromComicInfo(info);
}
if (string.IsNullOrEmpty(info.Series))
{

View File

@ -8,7 +8,7 @@ namespace API.Services.Tasks.Scanner.Parser;
public interface IDefaultParser
{
ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null);
ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null);
void ParseFromFallbackFolders(string filePath, string rootPath, LibraryType type, ref ParserInfo ret);
bool IsApplicable(string filePath, LibraryType type);
}
@ -26,8 +26,9 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau
/// <param name="filePath"></param>
/// <param name="rootPath">Root folder</param>
/// <param name="type">Allows different Regex to be used for parsing.</param>
/// <param name="enableMetadata">Allows overriding data from metadata (ComicInfo/pdf/epub)</param>
/// <returns><see cref="ParserInfo"/> or null if Series was empty</returns>
public abstract ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null);
public abstract ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null);
/// <summary>
/// Fills out <see cref="ParserInfo"/> by trying to parse volume, chapters, and series from folders

View File

@ -7,7 +7,7 @@ namespace API.Services.Tasks.Scanner.Parser;
public class ImageParser(IDirectoryService directoryService) : DefaultParser(directoryService)
{
public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null)
public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null)
{
if (!IsApplicable(filePath, type)) return null;

View File

@ -165,9 +165,9 @@ public static partial class Parser
new Regex(
@"(卷|册)(?<Volume>\d+)",
MatchOptions, RegexTimeout),
// Korean Volume: 제n화|권|회|장 -> Volume n, n화|권|회|장 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside)
// Korean Volume: 제n화|회|장 -> Volume n, n화|권|장 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside)
new Regex(
@"제?(?<Volume>\d+(\.\d+)?)(권|회|화|장)",
@"제?(?<Volume>\d+(\.\d+)?)(권|화|장)",
MatchOptions, RegexTimeout),
// Korean Season: 시즌n -> Season n,
new Regex(

View File

@ -6,7 +6,7 @@ namespace API.Services.Tasks.Scanner.Parser;
public class PdfParser(IDirectoryService directoryService) : DefaultParser(directoryService)
{
public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo comicInfo = null)
public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo comicInfo = null)
{
var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath);
var ret = new ParserInfo
@ -68,14 +68,18 @@ public class PdfParser(IDirectoryService directoryService) : DefaultParser(direc
ParseFromFallbackFolders(filePath, tempRootPath, type, ref ret);
}
// Patch in other information from ComicInfo
UpdateFromComicInfo(ret);
if (comicInfo != null && !string.IsNullOrEmpty(comicInfo.Title))
if (enableMetadata)
{
ret.Title = comicInfo.Title.Trim();
// Patch in other information from ComicInfo
UpdateFromComicInfo(ret);
if (comicInfo != null && !string.IsNullOrEmpty(comicInfo.Title))
{
ret.Title = comicInfo.Title.Trim();
}
}
if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.LooseLeafVolume && type == LibraryType.Book)
{
ret.IsSpecial = true;

View File

@ -521,6 +521,11 @@ public class ScannerService : IScannerService
// Validations are done, now we can start actual scan
_logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name);
if (!library.EnableMetadata)
{
_logger.LogInformation("[ScannerService] Warning! {LibraryName} has metadata turned off", library.Name);
}
// This doesn't work for something like M:/Manga/ and a series has library folder as root
var shouldUseLibraryScan = !(await _unitOfWork.LibraryRepository.DoAnySeriesFoldersMatch(libraryFolderPaths));
if (!shouldUseLibraryScan)

View File

@ -152,6 +152,10 @@ public static class MessageFactory
/// A Person merged has been merged into another
/// </summary>
public const string PersonMerged = "PersonMerged";
/// <summary>
/// A Rate limit error was hit when matching a series with Kavita+
/// </summary>
public const string ExternalMatchRateLimitError = "ExternalMatchRateLimitError";
public static SignalRMessage DashboardUpdateEvent(int userId)
{
@ -679,4 +683,16 @@ public static class MessageFactory
},
};
}
public static SignalRMessage ExternalMatchRateLimitErrorEvent(int seriesId, string seriesName)
{
return new SignalRMessage()
{
Name = ExternalMatchRateLimitError,
Body = new
{
seriesId = seriesId,
seriesName = seriesName,
},
};
}
}

View File

@ -17,7 +17,7 @@ public static class Configuration
private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());
public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development
? "http://localhost:5020" : "https://plus.kavitareader.com";
? "https://plus.kavitareader.com" : "https://plus.kavitareader.com"; // http://localhost:5020
public static readonly string StatsApiUrl = "https://stats.kavitareader.com";
public static int Port

View File

@ -9,12 +9,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Cronos" Version="0.10.0" />
<PackageReference Include="Cronos" Version="0.11.0" />
<PackageReference Include="DotNet.Glob" Version="3.1.3" />
<PackageReference Include="Flurl.Http" Version="4.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.9.0.115408">
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.11.0.117924">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@ -5,6 +5,11 @@
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: transform 0.2s ease, background 0.3s ease;
cursor: pointer;
&.not-selectable:hover {
cursor: not-allowed;
background-color: var(--bs-card-color, #2c2c2c) !important;
}
}
.tag-card:hover {

View File

@ -0,0 +1,120 @@
import {AbstractControl, FormArray, FormControl, FormGroup} from '@angular/forms';
interface ValidationIssue {
path: string;
controlType: string;
value: any;
errors: { [key: string]: any } | null;
status: string;
disabled: boolean;
}
export function analyzeFormGroupValidation(formGroup: FormGroup, basePath: string = ''): ValidationIssue[] {
const issues: ValidationIssue[] = [];
function analyzeControl(control: AbstractControl, path: string): void {
// Determine control type for better debugging
let controlType = 'AbstractControl';
if (control instanceof FormGroup) {
controlType = 'FormGroup';
} else if (control instanceof FormArray) {
controlType = 'FormArray';
} else if (control instanceof FormControl) {
controlType = 'FormControl';
}
// Add issue if control has validation errors or is invalid
if (control.invalid || control.errors || control.disabled) {
issues.push({
path: path || 'root',
controlType,
value: control.value,
errors: control.errors,
status: control.status,
disabled: control.disabled
});
}
// Recursively check nested controls
if (control instanceof FormGroup) {
Object.keys(control.controls).forEach(key => {
const childPath = path ? `${path}.${key}` : key;
analyzeControl(control.controls[key], childPath);
});
} else if (control instanceof FormArray) {
control.controls.forEach((childControl, index) => {
const childPath = path ? `${path}[${index}]` : `[${index}]`;
analyzeControl(childControl, childPath);
});
}
}
analyzeControl(formGroup, basePath);
return issues;
}
export function printFormGroupValidation(formGroup: FormGroup, basePath: string = ''): void {
const issues = analyzeFormGroupValidation(formGroup, basePath);
console.group(`🔍 FormGroup Validation Analysis (${basePath || 'root'})`);
console.log(`Overall Status: ${formGroup.status}`);
console.log(`Overall Valid: ${formGroup.valid}`);
console.log(`Total Issues Found: ${issues.length}`);
if (issues.length === 0) {
console.log('✅ No validation issues found!');
} else {
console.log('\n📋 Detailed Issues:');
issues.forEach((issue, index) => {
console.group(`${index + 1}. ${issue.path} (${issue.controlType})`);
console.log(`Status: ${issue.status}`);
console.log(`Value:`, issue.value);
console.log(`Disabled: ${issue.disabled}`);
if (issue.errors) {
console.log('Validation Errors:');
Object.entries(issue.errors).forEach(([errorKey, errorValue]) => {
console.log(`${errorKey}:`, errorValue);
});
} else {
console.log('No specific validation errors (but control is invalid)');
}
console.groupEnd();
});
}
console.groupEnd();
}
// Alternative function that returns a formatted string instead of console logging
export function getFormGroupValidationReport(formGroup: FormGroup, basePath: string = ''): string {
const issues = analyzeFormGroupValidation(formGroup, basePath);
let report = `FormGroup Validation Report (${basePath || 'root'})\n`;
report += `Overall Status: ${formGroup.status}\n`;
report += `Overall Valid: ${formGroup.valid}\n`;
report += `Total Issues Found: ${issues.length}\n\n`;
if (issues.length === 0) {
report += '✅ No validation issues found!';
} else {
report += 'Detailed Issues:\n';
issues.forEach((issue, index) => {
report += `\n${index + 1}. ${issue.path} (${issue.controlType})\n`;
report += ` Status: ${issue.status}\n`;
report += ` Value: ${JSON.stringify(issue.value)}\n`;
report += ` Disabled: ${issue.disabled}\n`;
if (issue.errors) {
report += ' Validation Errors:\n';
Object.entries(issue.errors).forEach(([errorKey, errorValue]) => {
report += `${errorKey}: ${JSON.stringify(errorValue)}\n`;
});
} else {
report += ' No specific validation errors (but control is invalid)\n';
}
});
}
return report;
}

View File

@ -0,0 +1,4 @@
export interface ExternalMatchRateLimitErrorEvent {
seriesId: number;
seriesName: string;
}

View File

@ -31,6 +31,7 @@ export interface Library {
manageReadingLists: boolean;
allowScrobbling: boolean;
allowMetadataMatching: boolean;
enableMetadata: boolean;
collapseSeriesRelationships: boolean;
libraryFileTypes: Array<FileTypeGroup>;
excludePatterns: Array<string>;

View File

@ -1,15 +1,16 @@
import { Injectable } from '@angular/core';
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
import { BehaviorSubject, ReplaySubject } from 'rxjs';
import { environment } from 'src/environments/environment';
import { LibraryModifiedEvent } from '../_models/events/library-modified-event';
import { NotificationProgressEvent } from '../_models/events/notification-progress-event';
import { ThemeProgressEvent } from '../_models/events/theme-progress-event';
import { UserUpdateEvent } from '../_models/events/user-update-event';
import { User } from '../_models/user';
import {Injectable} from '@angular/core';
import {HubConnection, HubConnectionBuilder} from '@microsoft/signalr';
import {BehaviorSubject, ReplaySubject} from 'rxjs';
import {environment} from 'src/environments/environment';
import {LibraryModifiedEvent} from '../_models/events/library-modified-event';
import {NotificationProgressEvent} from '../_models/events/notification-progress-event';
import {ThemeProgressEvent} from '../_models/events/theme-progress-event';
import {UserUpdateEvent} from '../_models/events/user-update-event';
import {User} from '../_models/user';
import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event";
import {SideNavUpdateEvent} from "../_models/events/sidenav-update-event";
import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event";
import {ExternalMatchRateLimitErrorEvent} from "../_models/events/external-match-rate-limit-error-event";
export enum EVENTS {
UpdateAvailable = 'UpdateAvailable',
@ -114,6 +115,10 @@ export enum EVENTS {
* A Person merged has been merged into another
*/
PersonMerged = 'PersonMerged',
/**
* A Rate limit error was hit when matching a series with Kavita+
*/
ExternalMatchRateLimitError = 'ExternalMatchRateLimitError'
}
export interface Message<T> {
@ -236,6 +241,13 @@ export class MessageHubService {
});
});
this.hubConnection.on(EVENTS.ExternalMatchRateLimitError, resp => {
this.messagesSource.next({
event: EVENTS.ExternalMatchRateLimitError,
payload: resp.body as ExternalMatchRateLimitErrorEvent
});
});
this.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => {
this.messagesSource.next({
event: EVENTS.NotificationProgress,

View File

@ -266,13 +266,13 @@ export class ReaderService {
getQueryParamsObject(incognitoMode: boolean = false, readingListMode: boolean = false, readingListId: number = -1) {
let params: {[key: string]: any} = {};
if (incognitoMode) {
params['incognitoMode'] = true;
}
const params: {[key: string]: any} = {};
params['incognitoMode'] = incognitoMode;
if (readingListMode) {
params['readingListId'] = readingListId;
}
return params;
}

View File

@ -1,7 +1,7 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
import {LicenseService} from "../../_services/license.service";
import {Router} from "@angular/router";
import {TranslocoDirective} from "@jsverse/transloco";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {ImageComponent} from "../../shared/image/image.component";
import {ImageService} from "../../_services/image.service";
import {Series} from "../../_models/series";
@ -23,6 +23,8 @@ import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
import {ScanSeriesEvent} from "../../_models/events/scan-series-event";
import {LibraryTypePipe} from "../../_pipes/library-type.pipe";
import {allKavitaPlusMetadataApplicableTypes} from "../../_models/library/library";
import {ExternalMatchRateLimitErrorEvent} from "../../_models/events/external-match-rate-limit-error-event";
import {ToastrService} from "ngx-toastr";
@Component({
selector: 'app-manage-matched-metadata',
@ -55,6 +57,7 @@ export class ManageMatchedMetadataComponent implements OnInit {
private readonly manageService = inject(ManageService);
private readonly messageHub = inject(MessageHubService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly toastr = inject(ToastrService);
protected readonly imageService = inject(ImageService);
@ -74,12 +77,19 @@ export class ManageMatchedMetadataComponent implements OnInit {
}
this.messageHub.messages$.subscribe(message => {
if (message.event !== EVENTS.ScanSeries) return;
const evt = message.payload as ScanSeriesEvent;
if (this.data.filter(d => d.series.id === evt.seriesId).length > 0) {
this.loadData();
if (message.event == EVENTS.ScanSeries) {
const evt = message.payload as ScanSeriesEvent;
if (this.data.filter(d => d.series.id === evt.seriesId).length > 0) {
this.loadData();
}
}
if (message.event == EVENTS.ExternalMatchRateLimitError) {
const evt = message.payload as ExternalMatchRateLimitErrorEvent;
this.toastr.error(translate('toasts.external-match-rate-error', {seriesName: evt.seriesName}))
}
});
this.filterGroup.valueChanges.pipe(

View File

@ -19,7 +19,7 @@
>
<ng-template #cardItem let-item let-position="idx">
<div class="tag-card" (click)="openFilter(FilterField.Genres, item.id)">
<div class="tag-card" [ngClass]="{'not-selectable': item.seriesCount === 0}" (click)="openFilter(FilterField.Genres, item)">
<div class="tag-name">{{ item.title }}</div>
<div class="tag-meta">
<span>{{t('series-count', {num: item.seriesCount | compactNumber})}}</span>

View File

@ -1,6 +1,6 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, OnInit} from '@angular/core';
import {CardDetailLayoutComponent} from "../../cards/card-detail-layout/card-detail-layout.component";
import {DecimalPipe} from "@angular/common";
import {DecimalPipe, NgClass} from "@angular/common";
import {
SideNavCompanionBarComponent
} from "../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
@ -24,7 +24,8 @@ import {Title} from "@angular/platform-browser";
DecimalPipe,
SideNavCompanionBarComponent,
TranslocoDirective,
CompactNumberPipe
CompactNumberPipe,
NgClass
],
templateUrl: './browse-genres.component.html',
styleUrl: './browse-genres.component.scss',
@ -62,7 +63,8 @@ export class BrowseGenresComponent implements OnInit {
});
}
openFilter(field: FilterField, value: string | number) {
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe();
openFilter(field: FilterField, genre: BrowseGenre) {
if (genre.seriesCount === 0) return; // We don't yet have an issue page
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${genre.id}`).subscribe();
}
}

View File

@ -19,7 +19,7 @@
>
<ng-template #cardItem let-item let-position="idx">
<div class="tag-card" (click)="openFilter(FilterField.Tags, item.id)">
<div class="tag-card" [ngClass]="{'not-selectable': item.seriesCount === 0}" (click)="openFilter(FilterField.Tags, item)">
<div class="tag-name">{{ item.title }}</div>
<div class="tag-meta">
<span>{{t('series-count', {num: item.seriesCount | compactNumber})}}</span>

View File

@ -1,6 +1,6 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, OnInit} from '@angular/core';
import {CardDetailLayoutComponent} from "../../cards/card-detail-layout/card-detail-layout.component";
import {DecimalPipe} from "@angular/common";
import {DecimalPipe, NgClass} from "@angular/common";
import {
SideNavCompanionBarComponent
} from "../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
@ -25,7 +25,8 @@ import {Title} from "@angular/platform-browser";
DecimalPipe,
SideNavCompanionBarComponent,
TranslocoDirective,
CompactNumberPipe
CompactNumberPipe,
NgClass
],
templateUrl: './browse-tags.component.html',
styleUrl: './browse-tags.component.scss',
@ -61,7 +62,8 @@ export class BrowseTagsComponent implements OnInit {
});
}
openFilter(field: FilterField, value: string | number) {
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe();
openFilter(field: FilterField, tag: BrowseTag) {
if (tag.seriesCount === 0) return; // We don't yet have an issue page
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${tag.id}`).subscribe();
}
}

View File

@ -229,14 +229,17 @@
</div>
}
<app-draggable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode"
[disabled]="!(formGroup.get('edit')?.value || false)" [showRemoveButton]="formGroup.get('edit')?.value || false">
<app-draggable-ordered-list [items]="items" [accessibilityMode]="accessibilityMode"
[disabled]="!(formGroup.get('edit')?.value || false)"
(orderUpdated)="orderUpdated($event)"
(itemRemove)="removeItem($event)"
[showRemoveButton]="formGroup.get('edit')?.value || false">
<ng-template #draggableItem let-item let-position="idx">
<app-reading-list-item [ngClass]="{'content-container': items.length < 100, 'non-virtualized-container': items.length >= 100}" [item]="item"
[position]="position" [libraryTypes]="libraryTypes"
[promoted]="item.promoted" (read)="readChapter($event)"
(remove)="itemRemoved($event, position)"
(remove)="removeItem($event)"
[showRemove]="false"/>
</ng-template>
</app-draggable-ordered-list>

View File

@ -25,7 +25,8 @@ import {ImageService} from 'src/app/_services/image.service';
import {ReadingListService} from 'src/app/_services/reading-list.service';
import {
DraggableOrderedListComponent,
IndexUpdateEvent
IndexUpdateEvent,
ItemRemoveEvent
} from '../draggable-ordered-list/draggable-ordered-list.component';
import {forkJoin, startWith, tap} from 'rxjs';
import {ReaderService} from 'src/app/_services/reader.service';
@ -321,6 +322,7 @@ export class ReadingListDetailComponent implements OnInit {
}
editReadingList(readingList: ReadingList) {
if (!readingList) return;
this.actionService.editReadingList(readingList, (readingList: ReadingList) => {
// Reload information around list
this.readingListService.getReadingList(this.listId).subscribe(rl => {
@ -347,10 +349,10 @@ export class ReadingListDetailComponent implements OnInit {
});
}
itemRemoved(item: ReadingListItem, position: number) {
removeItem(removeEvent: ItemRemoveEvent) {
if (!this.readingList) return;
this.readingListService.deleteItem(this.readingList.id, item.id).subscribe(() => {
this.items.splice(position, 1);
this.readingListService.deleteItem(this.readingList.id, removeEvent.item.id).subscribe(() => {
this.items.splice(removeEvent.position, 1);
this.items = [...this.items];
this.cdRef.markForCheck();
this.toastr.success(translate('toasts.item-removed'));

View File

@ -18,10 +18,10 @@
{{item.title}}
<div class="actions float-end">
@if (showRemove) {
<button class="btn btn-danger" (click)="remove.emit(item)">
<span>
<i class="fa fa-trash me-1" aria-hidden="true"></i>
</span>
<button class="btn btn-danger" (click)="removeItem(item)">
<span>
<i class="fa fa-trash me-1" aria-hidden="true"></i>
</span>
<span class="d-none d-md-inline-block">{{t('remove')}}</span>
</button>
}

View File

@ -9,6 +9,7 @@ import {ImageComponent} from '../../../shared/image/image.component';
import {TranslocoDirective} from "@jsverse/transloco";
import {SeriesFormatComponent} from "../../../shared/series-format/series-format.component";
import {ReadMoreComponent} from "../../../shared/read-more/read-more.component";
import {ItemRemoveEvent} from "../draggable-ordered-list/draggable-ordered-list.component";
@Component({
selector: 'app-reading-list-item',
@ -33,9 +34,16 @@ export class ReadingListItemComponent {
@Input() promoted: boolean = false;
@Output() read: EventEmitter<ReadingListItem> = new EventEmitter();
@Output() remove: EventEmitter<ReadingListItem> = new EventEmitter();
@Output() remove: EventEmitter<ItemRemoveEvent> = new EventEmitter();
readChapter(item: ReadingListItem) {
this.read.emit(item);
}
removeItem(item: ReadingListItem) {
this.remove.emit({
item: item,
position: item.order
});
}
}

View File

@ -61,7 +61,8 @@ export class ExternalRatingComponent implements OnInit {
ngOnInit() {
this.reviewService.overallRating(this.seriesId, this.chapterId).subscribe(r => {
this.overallRating = r.averageScore;
});
this.cdRef.markForCheck();
});
}
updateRating(rating: number) {
@ -92,6 +93,4 @@ export class ExternalRatingComponent implements OnInit {
return '';
}
protected readonly RatingAuthority = RatingAuthority;
}

View File

@ -127,6 +127,16 @@
</app-setting-item>
</div>
<div class="row g-0 mt-4 mb-4">
<app-setting-switch [title]="t('enable-metadata-label')" [subtitle]="t('enable-metadata-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input type="checkbox" id="enable-metadata" role="switch" formControlName="enableMetadata" class="form-check-input">
</div>
</ng-template>
</app-setting-switch>
</div>
<div class="row g-0 mt-4 mb-4">
<app-setting-switch [title]="t('manage-collection-label')" [subtitle]="t('manage-collection-tooltip')">
<ng-template #switch>

View File

@ -105,15 +105,16 @@ export class LibrarySettingsModalComponent implements OnInit {
libraryForm: FormGroup = new FormGroup({
name: new FormControl<string>('', { nonNullable: true, validators: [Validators.required] }),
type: new FormControl<LibraryType>(LibraryType.Manga, { nonNullable: true, validators: [Validators.required] }),
folderWatching: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
includeInDashboard: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
includeInRecommended: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
includeInSearch: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
manageCollections: new FormControl<boolean>(false, { nonNullable: true, validators: [Validators.required] }),
manageReadingLists: new FormControl<boolean>(false, { nonNullable: true, validators: [Validators.required] }),
allowScrobbling: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
allowMetadataMatching: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
collapseSeriesRelationships: new FormControl<boolean>(false, { nonNullable: true, validators: [Validators.required] }),
folderWatching: new FormControl<boolean>(true, { nonNullable: true, validators: [] }),
includeInDashboard: new FormControl<boolean>(true, { nonNullable: true, validators: [] }),
includeInRecommended: new FormControl<boolean>(true, { nonNullable: true, validators: [] }),
includeInSearch: new FormControl<boolean>(true, { nonNullable: true, validators: [] }),
manageCollections: new FormControl<boolean>(false, { nonNullable: true, validators: [] }),
manageReadingLists: new FormControl<boolean>(false, { nonNullable: true, validators: [] }),
allowScrobbling: new FormControl<boolean>(true, { nonNullable: true, validators: [] }),
allowMetadataMatching: new FormControl<boolean>(true, { nonNullable: true, validators: [] }),
collapseSeriesRelationships: new FormControl<boolean>(false, { nonNullable: true, validators: [] }),
enableMetadata: new FormControl<boolean>(true, { nonNullable: true, validators: [] }), // required validator doesn't check value, just if true
});
selectedFolders: string[] = [];
@ -155,7 +156,7 @@ export class LibrarySettingsModalComponent implements OnInit {
this.libraryForm.get('allowScrobbling')?.disable();
if (this.IsMetadataDownloadEligible) {
this.libraryForm.get('allowMetadataMatching')?.setValue(this.library.allowMetadataMatching);
this.libraryForm.get('allowMetadataMatching')?.setValue(this.library.allowMetadataMatching ?? true);
this.libraryForm.get('allowMetadataMatching')?.enable();
} else {
this.libraryForm.get('allowMetadataMatching')?.setValue(false);
@ -184,6 +185,20 @@ export class LibrarySettingsModalComponent implements OnInit {
this.setValues();
// Turn on/off manage collections/rl
this.libraryForm.get('enableMetadata')?.valueChanges.pipe(
tap(enabled => {
const manageCollectionsFc = this.libraryForm.get('manageCollections');
const manageReadingListsFc = this.libraryForm.get('manageReadingLists');
manageCollectionsFc?.setValue(enabled);
manageReadingListsFc?.setValue(enabled);
this.cdRef.markForCheck();
}),
takeUntilDestroyed(this.destroyRef)
).subscribe();
// This needs to only apply after first render
this.libraryForm.get('type')?.valueChanges.pipe(
tap((type: LibraryType) => {
@ -257,6 +272,8 @@ export class LibrarySettingsModalComponent implements OnInit {
this.libraryForm.get('collapseSeriesRelationships')?.setValue(this.library.collapseSeriesRelationships);
this.libraryForm.get('allowScrobbling')?.setValue(this.IsKavitaPlusEligible ? this.library.allowScrobbling : false);
this.libraryForm.get('allowMetadataMatching')?.setValue(this.IsMetadataDownloadEligible ? this.library.allowMetadataMatching : false);
this.libraryForm.get('excludePatterns')?.setValue(this.excludePatterns ? this.library.excludePatterns : false);
this.libraryForm.get('enableMetadata')?.setValue(this.library.enableMetadata, true);
this.selectedFolders = this.library.folders;
this.madeChanges = false;

View File

@ -1129,6 +1129,8 @@
"include-in-dashboard-tooltip": "Should series from the library be included on the Dashboard. This affects all streams, like On Deck, Recently Updated, Recently Added, or any custom additions.",
"include-in-search-label": "Include in Search",
"include-in-search-tooltip": "Should series and any derived information (genres, people, files) from the library be included in search results.",
"enable-metadata-label": "Enable Metadata (ComicInfo/Epub/PDF)",
"enable-metadata-tooltip": "Allow Kavita to read metadata files which override filename parsing.",
"force-scan": "Force Scan",
"force-scan-tooltip": "This will force a scan on the library, treating like a fresh scan",
"reset": "{{common.reset}}",
@ -2743,7 +2745,8 @@
"webtoon-override": "Switching to Webtoon mode due to images representing a webtoon.",
"scrobble-gen-init": "Enqueued a job to generate scrobble events from past reading history and ratings, syncing them with connected services.",
"series-bound-to-reading-profile": "Series bound to Reading Profile {{name}}",
"library-bound-to-reading-profile": "Library bound to Reading Profile {{name}}"
"library-bound-to-reading-profile": "Library bound to Reading Profile {{name}}",
"external-match-rate-error": "Kavita ran out of rate looking up {{seriesName}}. Try again in 5 minutes."
},
"read-time-pipe": {