mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Ability to turn off Metadata Parsing (#3872)
This commit is contained in:
parent
fa8d778c8d
commit
36aa5f5c85
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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")]
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
]
|
@ -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" />
|
||||
|
@ -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()
|
||||
|
@ -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; }
|
||||
|
@ -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; } = [];
|
||||
|
@ -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; }
|
||||
|
@ -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+
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
|
3709
API/Data/Migrations/20250620215058_EnableMetadataLibrary.Designer.cs
generated
Normal file
3709
API/Data/Migrations/20250620215058_EnableMetadataLibrary.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
API/Data/Migrations/20250620215058_EnableMetadataLibrary.cs
Normal file
29
API/Data/Migrations/20250620215058_EnableMetadataLibrary.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
@ -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");
|
||||
|
||||
|
@ -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; }
|
||||
|
@ -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;
|
||||
|
@ -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));
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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()!;
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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))
|
||||
|
@ -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))
|
||||
{
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
120
UI/Web/src/app/_helpers/form-debug.ts
Normal file
120
UI/Web/src/app/_helpers/form-debug.ts
Normal 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;
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
export interface ExternalMatchRateLimitErrorEvent {
|
||||
seriesId: number;
|
||||
seriesName: string;
|
||||
}
|
@ -31,6 +31,7 @@ export interface Library {
|
||||
manageReadingLists: boolean;
|
||||
allowScrobbling: boolean;
|
||||
allowMetadataMatching: boolean;
|
||||
enableMetadata: boolean;
|
||||
collapseSeriesRelationships: boolean;
|
||||
libraryFileTypes: Array<FileTypeGroup>;
|
||||
excludePatterns: Array<string>;
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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>
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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'));
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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": {
|
||||
|
Loading…
x
Reference in New Issue
Block a user