diff --git a/.gitignore b/.gitignore index 27f5cb538..71a904556 100644 --- a/.gitignore +++ b/.gitignore @@ -524,6 +524,7 @@ UI/Web/dist/ /API/config/Hangfire.db /API/config/Hangfire-log.db API/config/covers/ +API/config/images/* API/config/stats/* API/config/stats/app_stats.json API/config/pre-metadata/ @@ -539,3 +540,6 @@ BenchmarkDotNet.Artifacts API.Tests/Services/Test Data/ImageService/**/*_output* API.Tests/Services/Test Data/ImageService/**/*_baseline* API.Tests/Services/Test Data/ImageService/**/*.html + + +API.Tests/Services/Test Data/ScannerService/ScanTests/**/* diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index 7ae0e70fb..edf1af5eb 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -6,7 +6,7 @@ - + diff --git a/API.Tests/Services/ParseScannedFilesTests.cs b/API.Tests/Services/ParseScannedFilesTests.cs index 1bf2257ce..9fbb76ec3 100644 --- a/API.Tests/Services/ParseScannedFilesTests.cs +++ b/API.Tests/Services/ParseScannedFilesTests.cs @@ -25,13 +25,21 @@ using Xunit; namespace API.Tests.Services; -internal class MockReadingItemService : IReadingItemService +public class MockReadingItemService : IReadingItemService { - private readonly IDefaultParser _defaultParser; + private readonly BasicParser _basicParser; + private readonly ComicVineParser _comicVineParser; + private readonly ImageParser _imageParser; + private readonly BookParser _bookParser; + private readonly PdfParser _pdfParser; - public MockReadingItemService(IDefaultParser defaultParser) + public MockReadingItemService(IDirectoryService directoryService, IBookService bookService) { - _defaultParser = defaultParser; + _imageParser = new ImageParser(directoryService); + _basicParser = new BasicParser(directoryService, _imageParser); + _bookParser = new BookParser(directoryService, bookService, _basicParser); + _comicVineParser = new ComicVineParser(directoryService); + _pdfParser = new PdfParser(directoryService); } public ComicInfo GetComicInfo(string filePath) @@ -56,12 +64,33 @@ internal class MockReadingItemService : IReadingItemService public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type) { - return _defaultParser.Parse(path, rootPath, libraryRoot, type); + if (_comicVineParser.IsApplicable(path, type)) + { + return _comicVineParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + } + if (_imageParser.IsApplicable(path, type)) + { + return _imageParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + } + if (_bookParser.IsApplicable(path, type)) + { + return _bookParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + } + if (_pdfParser.IsApplicable(path, type)) + { + return _pdfParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + } + if (_basicParser.IsApplicable(path, type)) + { + return _basicParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + } + + return null; } public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type) { - return _defaultParser.Parse(path, rootPath, libraryRoot, type); + return Parse(path, rootPath, libraryRoot, type); } } @@ -175,7 +204,7 @@ public class ParseScannedFilesTests : AbstractDbTest var ds = new DirectoryService(Substitute.For>(), fileSystem); var psf = new ParseScannedFiles(Substitute.For>(), ds, - new MockReadingItemService(new BasicParser(ds, new ImageParser(ds))), Substitute.For()); + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); // var parsedSeries = new Dictionary>(); // @@ -239,7 +268,7 @@ public class ParseScannedFilesTests : AbstractDbTest var fileSystem = CreateTestFilesystem(); var ds = new DirectoryService(Substitute.For>(), fileSystem); var psf = new ParseScannedFiles(Substitute.For>(), ds, - new MockReadingItemService(new BasicParser(ds, new ImageParser(ds))), Substitute.For()); + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); var directoriesSeen = new HashSet(); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, @@ -259,7 +288,7 @@ public class ParseScannedFilesTests : AbstractDbTest var fileSystem = CreateTestFilesystem(); var ds = new DirectoryService(Substitute.For>(), fileSystem); var psf = new ParseScannedFiles(Substitute.For>(), ds, - new MockReadingItemService(new BasicParser(ds, new ImageParser(ds))), Substitute.For()); + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); @@ -294,7 +323,7 @@ public class ParseScannedFilesTests : AbstractDbTest var ds = new DirectoryService(Substitute.For>(), fileSystem); var psf = new ParseScannedFiles(Substitute.For>(), ds, - new MockReadingItemService(new BasicParser(ds, new ImageParser(ds))), Substitute.For()); + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); @@ -323,7 +352,7 @@ public class ParseScannedFilesTests : AbstractDbTest var ds = new DirectoryService(Substitute.For>(), fileSystem); var psf = new ParseScannedFiles(Substitute.For>(), ds, - new MockReadingItemService(new BasicParser(ds, new ImageParser(ds))), Substitute.For()); + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 0d0277e3e..14626e2dc 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -1,20 +1,55 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.IO.Compression; using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; using API.Entities; using API.Entities.Enums; -using API.Entities.Metadata; -using API.Extensions; +using API.Helpers; using API.Helpers.Builders; +using API.Services; +using API.Services.Plus; using API.Services.Tasks; +using API.Services.Tasks.Metadata; using API.Services.Tasks.Scanner; using API.Services.Tasks.Scanner.Parser; +using API.SignalR; using API.Tests.Helpers; +using Hangfire; +using Microsoft.Extensions.Logging; +using NSubstitute; using Xunit; +using Xunit.Abstractions; namespace API.Tests.Services; -public class ScannerServiceTests +public class ScannerServiceTests : AbstractDbTest { + private readonly ITestOutputHelper _testOutputHelper; + private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests"); + private readonly string _testcasesDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/TestCases"); + private readonly string _imagePath = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/1x1.png"); + + public ScannerServiceTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + + // Set up Hangfire to use in-memory storage for testing + GlobalConfiguration.Configuration.UseInMemoryStorage(); + } + + protected override async Task ResetDb() + { + _context.Library.RemoveRange(_context.Library); + await _context.SaveChangesAsync(); + } + [Fact] public void FindSeriesNotOnDisk_Should_Remove1() { @@ -68,4 +103,182 @@ public class ScannerServiceTests Assert.Empty(ScannerService.FindSeriesNotOnDisk(existingSeries, infos)); } + + [Fact] + public async Task ScanLibrary_ComicVine_PublisherFolder() + { + var testcase = "Publisher - ComicVine.json"; + var postLib = await GenerateScannerData(testcase); + + Assert.NotNull(postLib); + Assert.Equal(4, postLib.Series.Count); + + Assert.True(true); + } + + private async Task GenerateScannerData(string testcase) + { + var testDirectoryPath = await GenerateTestDirectory(Path.Join(_testcasesDirectory, testcase)); + _testOutputHelper.WriteLine($"Test Directory Path: {testDirectoryPath}"); + + var (publisher, type) = SplitPublisherAndLibraryType(Path.GetFileNameWithoutExtension(testcase)); + + var library = new LibraryBuilder(publisher, type) + .WithFolders([new FolderPath() {Path = testDirectoryPath}]) + .Build(); + + var admin = new AppUserBuilder("admin", "admin@kavita.com", Seed.DefaultThemes[0]) + .WithLibrary(library) + .Build(); + + _unitOfWork.UserRepository.Add(admin); // Admin is needed for generating collections/reading lists + _unitOfWork.LibraryRepository.Add(library); + await _unitOfWork.CommitAsync(); + + var ds = new DirectoryService(Substitute.For>(), new FileSystem()); + var mockReadingService = new MockReadingItemService(ds, Substitute.For()); + var processSeries = new ProcessSeries(_unitOfWork, Substitute.For>(), + Substitute.For(), + ds, Substitute.For(), mockReadingService, Substitute.For(), + Substitute.For(), + Substitute.For(), Substitute.For(), + Substitute.For(), + Substitute.For(), new TagManagerService(_unitOfWork, Substitute.For>())); + + var scanner = new ScannerService(_unitOfWork, Substitute.For>(), + Substitute.For(), + Substitute.For(), Substitute.For(), ds, + mockReadingService, processSeries, Substitute.For()); + + await scanner.ScanLibrary(library.Id); + + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + return postLib; + } + + private static (string Publisher, LibraryType Type) SplitPublisherAndLibraryType(string input) + { + // Split the input string based on " - " + var parts = input.Split(" - ", StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length != 2) + { + throw new ArgumentException("Input must be in the format 'Publisher - LibraryType'"); + } + + var publisher = parts[0].Trim(); + var libraryTypeString = parts[1].Trim(); + + // Try to parse the right-hand side as a LibraryType enum + if (!Enum.TryParse(libraryTypeString, out var libraryType)) + { + throw new ArgumentException($"'{libraryTypeString}' is not a valid LibraryType"); + } + + return (publisher, libraryType); + } + + + + private async Task GenerateTestDirectory(string mapPath) + { + // Read the map file + var mapContent = await File.ReadAllTextAsync(mapPath); + + // Deserialize the JSON content into a list of strings using System.Text.Json + var filePaths = JsonSerializer.Deserialize>(mapContent); + + // Create a test directory + var testDirectory = Path.Combine(_testDirectory, Path.GetFileNameWithoutExtension(mapPath)); + if (Directory.Exists(testDirectory)) + { + Directory.Delete(testDirectory, true); + } + Directory.CreateDirectory(testDirectory); + + // Generate the files and folders + await Scaffold(testDirectory, filePaths); + + return testDirectory; + } + + + private async Task Scaffold(string testDirectory, List filePaths) + { + foreach (var relativePath in filePaths) + { + var fullPath = Path.Combine(testDirectory, relativePath); + var fileDir = Path.GetDirectoryName(fullPath); + + // Create the directory if it doesn't exist + if (!Directory.Exists(fileDir)) + { + Directory.CreateDirectory(fileDir); + Console.WriteLine($"Created directory: {fileDir}"); + } + + var ext = Path.GetExtension(fullPath).ToLower(); + if (new[] { ".cbz", ".cbr", ".zip", ".rar" }.Contains(ext)) + { + CreateMinimalCbz(fullPath, includeMetadata: true); + } + else + { + // Create an empty file + await File.Create(fullPath).DisposeAsync(); + Console.WriteLine($"Created empty file: {fullPath}"); + } + } + } + + private void CreateMinimalCbz(string filePath, bool includeMetadata) + { + var tempImagePath = _imagePath; // Assuming _imagePath is a valid path to the 1x1 image + + using (var archive = ZipFile.Open(filePath, ZipArchiveMode.Create)) + { + // Add the 1x1 image to the archive + archive.CreateEntryFromFile(tempImagePath, "1x1.png"); + + if (includeMetadata) + { + var comicInfo = GenerateComicInfo(); + var entry = archive.CreateEntry("ComicInfo.xml"); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + writer.Write(comicInfo); + } + } + Console.WriteLine($"Created minimal CBZ archive: {filePath} with{(includeMetadata ? "" : "out")} metadata."); + } + + private string GenerateComicInfo() + { + var comicInfo = new StringBuilder(); + comicInfo.AppendLine(""); + comicInfo.AppendLine(""); + + // People Tags + string[] people = { /* Your list of people here */ }; + string[] genres = { /* Your list of genres here */ }; + + void AddRandomTag(string tagName, string[] choices) + { + if (new Random().Next(0, 2) == 1) // 50% chance to include the tag + { + var selected = choices.OrderBy(x => Guid.NewGuid()).Take(new Random().Next(1, 5)).ToArray(); + comicInfo.AppendLine($" <{tagName}>{string.Join(", ", selected)}"); + } + } + + foreach (var tag in new[] { "Writer", "Penciller", "Inker", "CoverArtist", "Publisher", "Character", "Imprint", "Colorist", "Letterer", "Editor", "Translator", "Team", "Location" }) + { + AddRandomTag(tag, people); + } + + AddRandomTag("Genre", genres); + comicInfo.AppendLine(""); + + return comicInfo.ToString(); + } } diff --git a/API.Tests/Services/Test Data/ScannerService/1x1.png b/API.Tests/Services/Test Data/ScannerService/1x1.png new file mode 100644 index 000000000..94381b429 Binary files /dev/null and b/API.Tests/Services/Test Data/ScannerService/1x1.png differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Books/PDFs/Rollo at Work SP01.pdf b/API.Tests/Services/Test Data/ScannerService/Library/Books/PDFs/Rollo at Work SP01.pdf deleted file mode 100644 index 35983f4e0..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Books/PDFs/Rollo at Work SP01.pdf and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Books/The Golden Harpoon/The Golden Harpoon.epub b/API.Tests/Services/Test Data/ScannerService/Library/Books/The Golden Harpoon/The Golden Harpoon.epub deleted file mode 100644 index 7388bc85e..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Books/The Golden Harpoon/The Golden Harpoon.epub and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Books/Vertical Reading/01.epub b/API.Tests/Services/Test Data/ScannerService/Library/Books/Vertical Reading/01.epub deleted file mode 100644 index 2850eed96..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Books/Vertical Reading/01.epub and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/Accel World Vol 1 Chapter 1.cbz b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/Accel World Vol 1 Chapter 1.cbz deleted file mode 100644 index d1eb76880..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/Accel World Vol 1 Chapter 1.cbz and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/Accel World Vol 1 Chapter 2.zip b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/Accel World Vol 1 Chapter 2.zip deleted file mode 100644 index 40ebeb13e..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/Accel World Vol 1 Chapter 2.zip and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/Accel World Vol 2 Chapter 3.zip b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/Accel World Vol 2 Chapter 3.zip deleted file mode 100644 index 40ebeb13e..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/Accel World Vol 2 Chapter 3.zip and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/ComicInfo.xml b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/ComicInfo.xml deleted file mode 100644 index 6bc41f434..000000000 --- a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/ComicInfo.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - Accel World - 2 - \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/ComicInfo.xml b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/ComicInfo.xml deleted file mode 100644 index d0494448f..000000000 --- a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/ComicInfo.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - Hajime no Ippo - 3 - M - \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/Hajime no Ippo Chapter 1.cbz b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/Hajime no Ippo Chapter 1.cbz deleted file mode 100644 index 895cfc415..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/Hajime no Ippo Chapter 1.cbz and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/Hajime no Ippo Chapter 2.zip b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/Hajime no Ippo Chapter 2.zip deleted file mode 100644 index 40ebeb13e..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/Hajime no Ippo Chapter 2.zip and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/Hajime no Ippo Chapter 3.zip b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/Hajime no Ippo Chapter 3.zip deleted file mode 100644 index 40ebeb13e..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/Hajime no Ippo Chapter 3.zip and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/001.jpg b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/001.jpg deleted file mode 100644 index b5c6de2aa..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/001.jpg and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/002.jpg b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/002.jpg deleted file mode 100644 index b5c6de2aa..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/002.jpg and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/003.jpg b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/003.jpg deleted file mode 100644 index b5c6de2aa..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/003.jpg and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/004.jpg b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/004.jpg deleted file mode 100644 index b5c6de2aa..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/004.jpg and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/README.md b/API.Tests/Services/Test Data/ScannerService/Library/README.md deleted file mode 100644 index 2969111b4..000000000 --- a/API.Tests/Services/Test Data/ScannerService/Library/README.md +++ /dev/null @@ -1 +0,0 @@ -This is an example of a layout. All files in here have non-copyrighted data but emulate real series to ensure the Process series Works as expected. \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/A Town Where You Live/A_Town_Where_You_Live_omake.zip b/API.Tests/Services/Test Data/ScannerService/Manga/A Town Where You Live/A_Town_Where_You_Live_omake.zip deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/A Town Where You Live/A_Town_Where_You_Live_v01.zip b/API.Tests/Services/Test Data/ScannerService/Manga/A Town Where You Live/A_Town_Where_You_Live_v01.zip deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/A Town Where You Live/A_Town_Where_You_Live_v02.zip b/API.Tests/Services/Test Data/ScannerService/Manga/A Town Where You Live/A_Town_Where_You_Live_v02.zip deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/A Town Where You Live/A_Town_Where_You_Live_v03.zip b/API.Tests/Services/Test Data/ScannerService/Manga/A Town Where You Live/A_Town_Where_You_Live_v03.zip deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/A Town Where You Live/A_Town_Where_You_Live_v04.zip b/API.Tests/Services/Test Data/ScannerService/Manga/A Town Where You Live/A_Town_Where_You_Live_v04.zip deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v01 (digital).cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v01 (digital).cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v02.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v02.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v03.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v03.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v04.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v04.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v05.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v05.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v06.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v06.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v07.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v07.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v08.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v08.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v09.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v09.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v10.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v10.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v11.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v11.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v12.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v12.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v13.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v13.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v14.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v14.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v15.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BEASTARS/BEASTARS v15.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v01.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v01.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v02.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v02.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v03.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v03.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v04.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v04.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v05.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v05.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v06.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v06.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v07.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v07.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v10.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v10.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Publisher - ComicVine.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Publisher - ComicVine.json new file mode 100644 index 000000000..f73beffff --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Publisher - ComicVine.json @@ -0,0 +1,22 @@ +[ + "Antarctic Press/Plush (2018)/Plush 002 (2019).cbz", + "Antarctic Press/Plush (2018)/Plush 001 (2018).cbz", + "12-Gauge Comics/Plush (2022)/Plush 1 (2022).cbz", + "12-Gauge Comics/Plush (2022)/Plush 2 (2022).cbz", + "12-Gauge Comics/Plush (2022)/Plush 3 (2023).cbz", + "12-Gauge Comics/Plush (2022)/Plush 004 (2023).cbz", + "12-Gauge Comics/Plush (2022)/Plush 005 (2023).cbz", + "12-Gauge Comics/Plush (2022)/Plush 006 (2023).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 009 (2023).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 1 (2022).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 2 (2022).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 3 (2023).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 004 (2023).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 005 (2023).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 006 (2023).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 007 (2023).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 008 (2023).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 010 (2024).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 011 (2024).cbz", + "Blood Hunters V2024 (2024)/Blood Hunters 001 (2024).cbz" +] \ No newline at end of file diff --git a/API/API.csproj b/API/API.csproj index d25705269..9cdecc8d1 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -56,7 +56,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -70,20 +70,20 @@ - + - - - - + + + + - + @@ -96,15 +96,15 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + diff --git a/API/Controllers/CBLController.cs b/API/Controllers/CBLController.cs index 79d5b4896..c49f915da 100644 --- a/API/Controllers/CBLController.cs +++ b/API/Controllers/CBLController.cs @@ -34,7 +34,7 @@ public class CblController : BaseApiController /// Use comic vine matching or not. Defaults to false /// [HttpPost("validate")] - public async Task> ValidateCbl(IFormFile cbl, [FromForm] bool comicVineMatching = false) + public async Task> ValidateCbl(IFormFile cbl, [FromQuery] bool comicVineMatching = false) { var userId = User.GetUserId(); try @@ -85,7 +85,7 @@ public class CblController : BaseApiController /// Use comic vine matching or not. Defaults to false /// [HttpPost("import")] - public async Task> ImportCbl(IFormFile cbl, [FromForm] bool dryRun = false, [FromForm] bool comicVineMatching = false) + public async Task> ImportCbl(IFormFile cbl, [FromQuery] bool dryRun = false, [FromQuery] bool comicVineMatching = false) { try { diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index 646a720cd..6275c6d4c 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -242,6 +242,46 @@ public class ImageController : BaseApiController return PhysicalFile(file.FullName, MimeTypeMap.GetMimeType(format), Path.GetFileName(file.FullName)); } + + /// + /// Returns the image associated with a publisher + /// + /// + /// + /// + [HttpGet("publisher")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = ["publisherName", "apiKey"])] + public async Task GetPublisherImage(string publisherName, string apiKey) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + if (userId == 0) return BadRequest(); + if (string.IsNullOrEmpty(publisherName)) return BadRequest(await _localizationService.Translate(userId, "must-be-defined", "publisherName")); + if (publisherName.Contains("..")) return BadRequest(); + + var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs; + + // Check if the domain exists + var domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.PublisherDirectory, ImageService.GetPublisherFormat(publisherName, encodeFormat)); + if (!_directoryService.FileSystem.File.Exists(domainFilePath)) + { + // We need to request the favicon and save it + try + { + domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.PublisherDirectory, + await _imageService.DownloadPublisherImageAsync(publisherName, encodeFormat)); + } + catch (Exception) + { + return BadRequest(await _localizationService.Translate(userId, "generic-favicon")); + } + } + + var file = new FileInfo(domainFilePath); + var format = Path.GetExtension(file.FullName); + + return PhysicalFile(file.FullName, MimeTypeMap.GetMimeType(format), Path.GetFileName(file.FullName)); + } + /// /// Returns a temp coverupload image /// diff --git a/API/Entities/Enums/LibraryType.cs b/API/Entities/Enums/LibraryType.cs index 8e81395cf..40d1b10a8 100644 --- a/API/Entities/Enums/LibraryType.cs +++ b/API/Entities/Enums/LibraryType.cs @@ -34,5 +34,4 @@ public enum LibraryType /// [Description("Comic (Comic Vine)")] ComicVine = 5, - } diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 8c6c796c9..b290573e2 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -29,6 +29,7 @@ public interface IDirectoryService string LocalizationDirectory { get; } string CustomizedTemplateDirectory { get; } string TemplateDirectory { get; } + string PublisherDirectory { get; } /// /// Original BookmarkDirectory. Only used for resetting directory. Use for actual path. /// @@ -88,6 +89,7 @@ public class DirectoryService : IDirectoryService public string LocalizationDirectory { get; } public string CustomizedTemplateDirectory { get; } public string TemplateDirectory { get; } + public string PublisherDirectory { get; } private readonly ILogger _logger; private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase; @@ -125,6 +127,8 @@ public class DirectoryService : IDirectoryService ExistOrCreate(CustomizedTemplateDirectory); TemplateDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "EmailTemplates"); ExistOrCreate(TemplateDirectory); + PublisherDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "images", "publishers"); + ExistOrCreate(PublisherDirectory); } /// diff --git a/API/Services/HostedServices/StartupTasksHostedService.cs b/API/Services/HostedServices/StartupTasksHostedService.cs index 37b6effee..145fb8e2b 100644 --- a/API/Services/HostedServices/StartupTasksHostedService.cs +++ b/API/Services/HostedServices/StartupTasksHostedService.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using API.Data; using API.Services.Tasks.Scanner; +using Hangfire; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -45,7 +46,8 @@ public class StartupTasksHostedService : IHostedService if ((await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableFolderWatching) { var libraryWatcher = scope.ServiceProvider.GetRequiredService(); - await libraryWatcher.StartWatching(); + // Push this off for a bit for people with massive libraries, as it can take up to 45 mins and blocks the thread + BackgroundJob.Enqueue(() => libraryWatcher.StartWatching()); } } catch (Exception) diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index eb9f49263..60b5c99fd 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -67,6 +67,7 @@ public interface IImageService Task ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat); Task IsImage(string filePath); Task DownloadFaviconAsync(string url, EncodeFormat encodeFormat); + Task DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat); void UpdateColorScape(IHasCoverImage entity); } @@ -380,7 +381,7 @@ public class ImageService : IImageService { if (string.IsNullOrEmpty(correctSizeLink)) { - correctSizeLink = FallbackToKavitaReaderFavicon(baseUrl); + correctSizeLink = await FallbackToKavitaReaderFavicon(baseUrl); } if (string.IsNullOrEmpty(correctSizeLink)) { @@ -424,11 +425,57 @@ public class ImageService : IImageService } - _logger.LogDebug("Favicon.png for {Domain} downloaded and saved successfully", domain); + _logger.LogDebug("Favicon for {Domain} downloaded and saved successfully", domain); return filename; } catch (Exception ex) { - _logger.LogError(ex, "Error downloading favicon.png for {Domain}", domain); + _logger.LogError(ex, "Error downloading favicon for {Domain}", domain); + throw; + } + } + + public async Task DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat) + { + try + { + var publisherLink = await FallbackToKavitaReaderPublisher(publisherName); + if (string.IsNullOrEmpty(publisherLink)) + { + throw new KavitaException($"Could not grab publisher image for {publisherName}"); + } + + var finalUrl = publisherLink; + + _logger.LogTrace("Fetching publisher image from {Url}", finalUrl); + // Download the favicon.ico file using Flurl + var publisherStream = await finalUrl + .AllowHttpStatus("2xx,304") + .GetStreamAsync(); + + // Create the destination file path + using var image = Image.PngloadStream(publisherStream); + var filename = GetPublisherFormat(publisherName, encodeFormat); + switch (encodeFormat) + { + case EncodeFormat.PNG: + image.Pngsave(Path.Combine(_directoryService.PublisherDirectory, filename)); + break; + case EncodeFormat.WEBP: + image.Webpsave(Path.Combine(_directoryService.PublisherDirectory, filename)); + break; + case EncodeFormat.AVIF: + image.Heifsave(Path.Combine(_directoryService.PublisherDirectory, filename)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); + } + + + _logger.LogDebug("Publisher image for {PublisherName} downloaded and saved successfully", publisherName); + return filename; + } catch (Exception ex) + { + _logger.LogError(ex, "Error downloading image for {PublisherName}", publisherName); throw; } } @@ -565,18 +612,12 @@ public class ImageService : IImageService return centroids; } - // public static Vector3 GetComplementaryColor(Vector3 color) - // { - // // Simple complementary color calculation - // return new Vector3(255 - color.X, 255 - color.Y, 255 - color.Z); - // } - public static List SortByBrightness(List colors) { return colors.OrderBy(c => 0.299 * c.X + 0.587 * c.Y + 0.114 * c.Z).ToList(); } - public static List SortByVibrancy(List colors) + private static List SortByVibrancy(List colors) { return colors.OrderByDescending(c => { @@ -686,10 +727,10 @@ public class ImageService : IImageService }; } - private static string FallbackToKavitaReaderFavicon(string baseUrl) + private static async Task FallbackToKavitaReaderFavicon(string baseUrl) { var correctSizeLink = string.Empty; - var allOverrides = "https://kavitareader.com/assets/favicons/urls.txt".GetStringAsync().Result; + var allOverrides = await "https://www.kavitareader.com/assets/favicons/urls.txt".GetStringAsync(); if (!string.IsNullOrEmpty(allOverrides)) { var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty); @@ -699,17 +740,51 @@ public class ImageService : IImageService cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) || cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty) )); + if (string.IsNullOrEmpty(externalFile)) { throw new KavitaException($"Could not grab favicon from {baseUrl}"); } - correctSizeLink = "https://kavitareader.com/assets/favicons/" + externalFile; + correctSizeLink = "https://www.kavitareader.com/assets/favicons/" + externalFile; } return correctSizeLink; } + private static async Task FallbackToKavitaReaderPublisher(string publisherName) + { + var externalLink = string.Empty; + var allOverrides = await "https://www.kavitareader.com/assets/publishers/publishers.txt".GetStringAsync(); + if (!string.IsNullOrEmpty(allOverrides)) + { + var externalFile = allOverrides + .Split("\n") + .Select(publisherLine => + { + var tokens = publisherLine.Split("|"); + if (tokens.Length != 2) return null; + var aliases = tokens[0]; + // Multiple publisher aliases are separated by # + if (aliases.Split("#").Any(name => name.ToLowerInvariant().Trim().Equals(publisherName.ToLowerInvariant().Trim()))) + { + return tokens[1]; + } + return null; + }) + .FirstOrDefault(url => !string.IsNullOrEmpty(url)); + + if (string.IsNullOrEmpty(externalFile)) + { + throw new KavitaException($"Could not grab publisher image for {publisherName}"); + } + + externalLink = "https://www.kavitareader.com/assets/publishers/" + externalFile; + } + + return externalLink; + } + /// public string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = ThumbnailWidth) { @@ -805,6 +880,11 @@ public class ImageService : IImageService return $"{new Uri(url).Host.Replace("www.", string.Empty)}{encodeFormat.GetExtension()}"; } + public static string GetPublisherFormat(string publisher, EncodeFormat encodeFormat) + { + return $"{publisher}{encodeFormat.GetExtension()}"; + } + public static void CreateMergedImage(IList coverImages, CoverImageSize size, string dest) { @@ -891,4 +971,6 @@ public class ImageService : IImageService return Color.FromArgb(r, g, b); } + + } diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index b99f13ae8..653e54d3e 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -14,7 +14,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/UI/Web/src/_card-item-common.scss b/UI/Web/src/_card-item-common.scss index 0634e574f..20dae728c 100644 --- a/UI/Web/src/_card-item-common.scss +++ b/UI/Web/src/_card-item-common.scss @@ -1,6 +1,6 @@ -$image-height: 230px; +$image-height: 232.91px; $image-width: 160px; .error-banner { @@ -118,7 +118,7 @@ $image-width: 160px; top: 0; left: 0; width: 100%; - height: 230px; + height: 232.91px; transition: all 0.2s; border-top-left-radius: 4px; border-top-right-radius: 4px; diff --git a/UI/Web/src/app/_pipes/provider-image.pipe.ts b/UI/Web/src/app/_pipes/provider-image.pipe.ts index 75e656651..80574ef3b 100644 --- a/UI/Web/src/app/_pipes/provider-image.pipe.ts +++ b/UI/Web/src/app/_pipes/provider-image.pipe.ts @@ -7,16 +7,16 @@ import {ScrobbleProvider} from "../_services/scrobbling.service"; }) export class ProviderImagePipe implements PipeTransform { - transform(value: ScrobbleProvider): string { + transform(value: ScrobbleProvider, large: boolean = false): string { switch (value) { case ScrobbleProvider.AniList: - return 'assets/images/ExternalServices/AniList.png'; + return `assets/images/ExternalServices/AniList${large ? '-lg' : ''}.png`; case ScrobbleProvider.Mal: - return 'assets/images/ExternalServices/MAL.png'; + return `assets/images/ExternalServices/MAL${large ? '-lg' : ''}.png`; case ScrobbleProvider.GoogleBooks: - return 'assets/images/ExternalServices/GoogleBooks.png'; + return `assets/images/ExternalServices/GoogleBooks${large ? '-lg' : ''}.png`; case ScrobbleProvider.Kavita: - return 'assets/images/logo-32.png'; + return `assets/images/logo-${large ? '64' : '32'}.png`; } } diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 222c46a45..72eb69931 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -556,7 +556,7 @@ export class ActionService { if (this.collectionModalRef != null) { return; } this.collectionModalRef = this.modalService.open(BulkAddToCollectionComponent, { scrollable: true, size: 'md', windowClass: 'collection', fullscreen: 'md' }); this.collectionModalRef.componentInstance.seriesIds = series.map(v => v.id); - this.collectionModalRef.componentInstance.title = translate('action.new-collection'); + this.collectionModalRef.componentInstance.title = translate('actionable.new-collection'); this.collectionModalRef.closed.pipe(take(1)).subscribe(() => { this.collectionModalRef = null; diff --git a/UI/Web/src/app/_services/colorscape.service.ts b/UI/Web/src/app/_services/colorscape.service.ts index 9ea2a845f..c067d2c20 100644 --- a/UI/Web/src/app/_services/colorscape.service.ts +++ b/UI/Web/src/app/_services/colorscape.service.ts @@ -217,14 +217,22 @@ export class ColorscapeService { private setColorsImmediately(colors: ColorSpaceRGBA) { this.injectStyleElement(colorScapeSelector, ` :root, :root .default { - --colorscape-primary-color: ${this.rgbaToString(colors.primary)}; - --colorscape-lighter-color: ${this.rgbaToString(colors.lighter)}; - --colorscape-darker-color: ${this.rgbaToString(colors.darker)}; - --colorscape-complementary-color: ${this.rgbaToString(colors.complementary)}; - --colorscape-primary-alpha-color: ${this.rgbaToString({ ...colors.primary, a: 0 })}; - --colorscape-lighter-alpha-color: ${this.rgbaToString({ ...colors.lighter, a: 0 })}; - --colorscape-darker-alpha-color: ${this.rgbaToString({ ...colors.darker, a: 0 })}; - --colorscape-complementary-alpha-color: ${this.rgbaToString({ ...colors.complementary, a: 0 })}; + --colorscape-primary-color: ${this.rgbToString(colors.primary)}; + --colorscape-lighter-color: ${this.rgbToString(colors.lighter)}; + --colorscape-darker-color: ${this.rgbToString(colors.darker)}; + --colorscape-complementary-color: ${this.rgbToString(colors.complementary)}; + --colorscape-primary-no-alpha-color: ${this.rgbaToString({ ...colors.primary, a: 0 })}; + --colorscape-lighter-no-alpha-color: ${this.rgbaToString({ ...colors.lighter, a: 0 })}; + --colorscape-darker-no-alpha-color: ${this.rgbaToString({ ...colors.darker, a: 0 })}; + --colorscape-complementary-no-alpha-color: ${this.rgbaToString({ ...colors.complementary, a: 0 })}; + --colorscape-primary-full-alpha-color: ${this.rgbaToString({ ...colors.primary, a: 1 })}; + --colorscape-lighter-full-alpha-color: ${this.rgbaToString({ ...colors.lighter, a: 1 })}; + --colorscape-darker-full-alpha-color: ${this.rgbaToString({ ...colors.darker, a: 1 })}; + --colorscape-complementary-full-alpha-color: ${this.rgbaToString({ ...colors.complementary, a: 1 })}; + --colorscape-primary-half-alpha-color: ${this.rgbaToString({ ...colors.primary, a: 0.5 })}; + --colorscape-lighter-half-alpha-color: ${this.rgbaToString({ ...colors.lighter, a: 0.5 })}; + --colorscape-darker-half-alpha-color: ${this.rgbaToString({ ...colors.darker, a: 0.5 })}; + --colorscape-complementary-half-alpha-color: ${this.rgbaToString({ ...colors.complementary, a: 0.5 })}; } `); } @@ -362,6 +370,10 @@ export class ColorscapeService { return `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`; } + private rgbToString(color: RGBAColor): string { + return `rgb(${color.r}, ${color.g}, ${color.b})`; + } + private getCssVariable(variableName: string): string { return getComputedStyle(this.document.body).getPropertyValue(variableName).trim(); } diff --git a/UI/Web/src/app/_services/image.service.ts b/UI/Web/src/app/_services/image.service.ts index 2c85f2605..e9dba7420 100644 --- a/UI/Web/src/app/_services/image.service.ts +++ b/UI/Web/src/app/_services/image.service.ts @@ -91,6 +91,10 @@ export class ImageService { return `${this.baseUrl}image/web-link?url=${encodeURIComponent(url)}&apiKey=${this.encodedKey}`; } + getPublisherImage(name: string) { + return `${this.baseUrl}image/publisher?publisherName=${encodeURIComponent(name)}&apiKey=${this.encodedKey}`; + } + getCoverUploadImage(filename: string) { return `${this.baseUrl}image/cover-upload?filename=${encodeURIComponent(filename)}&apiKey=${this.encodedKey}`; } diff --git a/UI/Web/src/app/_services/reading-list.service.ts b/UI/Web/src/app/_services/reading-list.service.ts index a962cdaac..04d97060f 100644 --- a/UI/Web/src/app/_services/reading-list.service.ts +++ b/UI/Web/src/app/_services/reading-list.service.ts @@ -106,12 +106,12 @@ export class ReadingListService { return this.httpClient.get(this.baseUrl + 'readinglist/name-exists?name=' + name); } - validateCbl(form: FormData) { - return this.httpClient.post(this.baseUrl + 'cbl/validate', form); + validateCbl(form: FormData, dryRun: boolean, useComicVineMatching: boolean) { + return this.httpClient.post(this.baseUrl + `cbl/validate?dryRun=${dryRun}&useComicVineMatching=${useComicVineMatching}`, form); } - importCbl(form: FormData) { - return this.httpClient.post(this.baseUrl + 'cbl/import', form); + importCbl(form: FormData, dryRun: boolean, useComicVineMatching: boolean) { + return this.httpClient.post(this.baseUrl + `cbl/import?dryRun=${dryRun}&useComicVineMatching=${useComicVineMatching}`, form); } getCharacters(readingListId: number) { diff --git a/UI/Web/src/app/_single-module/details-tab/details-tab.component.html b/UI/Web/src/app/_single-module/details-tab/details-tab.component.html index ca7f8e75d..5c3dcc9b9 100644 --- a/UI/Web/src/app/_single-module/details-tab/details-tab.component.html +++ b/UI/Web/src/app/_single-module/details-tab/details-tab.component.html @@ -20,6 +20,17 @@ +
+ + + + + + + +
+ @if (genres.length > 0 || tags.length > 0) { } diff --git a/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts b/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts index 2c6d7348c..d93619700 100644 --- a/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts +++ b/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts @@ -11,30 +11,37 @@ import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.se import {Genre} from "../../_models/metadata/genre"; import {Tag} from "../../_models/tag"; import {TagBadgeComponent, TagBadgeCursor} from "../../shared/tag-badge/tag-badge.component"; +import {ImageComponent} from "../../shared/image/image.component"; +import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; +import {ImageService} from "../../_services/image.service"; @Component({ selector: 'app-details-tab', standalone: true, - imports: [ - CarouselReelComponent, - PersonBadgeComponent, - TranslocoDirective, - TagBadgeComponent - ], + imports: [ + CarouselReelComponent, + PersonBadgeComponent, + TranslocoDirective, + TagBadgeComponent, + ImageComponent, + SafeHtmlPipe + ], templateUrl: './details-tab.component.html', styleUrl: './details-tab.component.scss', changeDetection: ChangeDetectionStrategy.OnPush }) export class DetailsTabComponent { - private readonly router = inject(Router); + protected readonly imageService = inject(ImageService); private readonly filterUtilityService = inject(FilterUtilitiesService); + protected readonly PersonRole = PersonRole; protected readonly FilterField = FilterField; @Input({required: true}) metadata!: IHasCast; @Input() genres: Array = []; @Input() tags: Array = []; + @Input() webLinks: Array = []; openPerson(queryParamName: FilterField, filter: Person) { diff --git a/UI/Web/src/app/_single-module/series-preview-drawer/series-preview-drawer.component.html b/UI/Web/src/app/_single-module/series-preview-drawer/series-preview-drawer.component.html index aab1d8209..145db1310 100644 --- a/UI/Web/src/app/_single-module/series-preview-drawer/series-preview-drawer.component.html +++ b/UI/Web/src/app/_single-module/series-preview-drawer/series-preview-drawer.component.html @@ -10,7 +10,7 @@
- +
diff --git a/UI/Web/src/app/app.component.scss b/UI/Web/src/app/app.component.scss index 8ec4b09ae..b5e95f25e 100644 --- a/UI/Web/src/app/app.component.scss +++ b/UI/Web/src/app/app.component.scss @@ -46,16 +46,16 @@ .default-background { background: radial-gradient(circle farthest-side at 0% 100%, var(--colorscape-darker-color) 0%, - var(--colorscape-darker-alpha-color) 100%), + var(--colorscape-darker-no-alpha-color) 100%), radial-gradient(circle farthest-side at 100% 100%, var(--colorscape-primary-color) 0%, - var(--colorscape-primary-alpha-color) 100%), + var(--colorscape-primary-no-alpha-color) 100%), radial-gradient(circle farthest-side at 100% 0%, var(--colorscape-lighter-color) 0%, - var(--colorscape-lighter-alpha-color) 100%), + var(--colorscape-lighter-no-alpha-color) 100%), radial-gradient(circle farthest-side at 0% 0%, var(--colorscape-complementary-color) 0%, - var(--colorscape-complementary-alpha-color) 100%), + var(--colorscape-complementary-no-alpha-color) 100%), var(--bs-body-bg); } @@ -68,6 +68,9 @@ z-index: -1; pointer-events: none; background-color: #121212; + filter: blur(20px); + object-fit: contain; + transform: scale(1.1); .background-area { position: absolute; diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index e92a615f9..a2b3b7437 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -97,6 +97,7 @@ export class AppComponent implements OnInit { // Sets a CSS variable for the actual device viewport height. Needed for mobile dev. const vh = window.innerHeight * 0.01; this.document.documentElement.style.setProperty('--vh', `${vh}px`); + this.utilityService.activeBreakpointSource.next(this.utilityService.getActiveBreakpoint()); } ngOnInit(): void { diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html index ec7ce2e8f..677a96962 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html @@ -1,748 +1,758 @@ - - -
- + {{t('field-locked-alt')}} - + + } + diff --git a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts index 5464568d3..3df27ed09 100644 --- a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts +++ b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts @@ -146,7 +146,7 @@ export class CardDetailDrawerComponent implements OnInit { this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)) .filter(item => item.action !== Action.Edit); - this.chapterActions.push({title: 'read', description: 'read-tooltip', action: Action.Read, callback: this.handleChapterActionCallback.bind(this), requiresAdmin: false, children: []}); + this.chapterActions.push({title: 'read', description: '', action: Action.Read, callback: this.handleChapterActionCallback.bind(this), requiresAdmin: false, children: []}); if (this.isChapter) { const chapter = this.utilityService.asChapter(this.data); this.chapterActions = this.actionFactoryService.filterSendToAction(this.chapterActions, chapter); diff --git a/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.scss b/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.scss index 8c31c6203..3dad5235c 100644 --- a/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.scss +++ b/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.scss @@ -1,4 +1,4 @@ -$image-height: 230px; +$image-height: 232.91px; $image-width: 160px; .card-img-top { diff --git a/UI/Web/src/app/cards/entity-title/entity-title.component.html b/UI/Web/src/app/cards/entity-title/entity-title.component.html index 3161a6585..8b13f263d 100644 --- a/UI/Web/src/app/cards/entity-title/entity-title.component.html +++ b/UI/Web/src/app/cards/entity-title/entity-title.component.html @@ -37,11 +37,19 @@ } @case (LibraryType.Book) { - {{volumeTitle}} + @if (titleName !== '' && prioritizeTitleName) { + {{titleName}} + } @else { + {{volumeTitle}} + } } @case (LibraryType.LightNovel) { - {{volumeTitle}} + @if (titleName !== '' && prioritizeTitleName) { + {{titleName}} + } @else { + {{volumeTitle}} + } } @case (LibraryType.Images) { diff --git a/UI/Web/src/app/cards/series-card/series-card.component.ts b/UI/Web/src/app/cards/series-card/series-card.component.ts index 17e014067..f451b7919 100644 --- a/UI/Web/src/app/cards/series-card/series-card.component.ts +++ b/UI/Web/src/app/cards/series-card/series-card.component.ts @@ -227,7 +227,7 @@ export class SeriesCardComponent implements OnInit, OnChanges { this.scanLibrary(series); break; case(Action.RefreshMetadata): - this.refreshMetadata(series); + this.refreshMetadata(series, true); break; case(Action.GenerateColorScape): this.refreshMetadata(series, false); diff --git a/UI/Web/src/app/cards/volume-card/volume-card.component.html b/UI/Web/src/app/cards/volume-card/volume-card.component.html index 53467480b..93e2d0228 100644 --- a/UI/Web/src/app/cards/volume-card/volume-card.component.html +++ b/UI/Web/src/app/cards/volume-card/volume-card.component.html @@ -60,7 +60,7 @@
- + {{volume.name}} diff --git a/UI/Web/src/app/chapter-detail/chapter-detail.component.html b/UI/Web/src/app/chapter-detail/chapter-detail.component.html index aa853c20a..078c9a110 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.html +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.html @@ -7,7 +7,7 @@
- @if (chapter.pagesRead < chapter.pages && hasReadingProgress) { + @if (chapter.pagesRead < chapter.pages && chapter.pagesRead > 0) {
@@ -41,7 +41,7 @@ @@ -64,8 +64,8 @@
@@ -74,7 +74,7 @@
@@ -100,7 +100,7 @@
- +
@@ -111,9 +111,6 @@ {{item.name}} - @if (!last) { - , - }
@@ -124,9 +121,6 @@ {{item.name}} - @if (!last) { - , - }
diff --git a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts index 4d2bf5f66..fb0eb5573 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts @@ -75,6 +75,7 @@ import {DownloadButtonComponent} from "../series-detail/_components/download-but import {hasAnyCast} from "../_models/common/i-has-cast"; import {CarouselTabComponent} from "../carousel/_components/carousel-tab/carousel-tab.component"; import {CarouselTabsComponent, TabId} from "../carousel/_components/carousel-tabs/carousel-tabs.component"; +import {Breakpoint, UtilityService} from "../shared/_services/utility.service"; enum TabID { Related = 'related-tab', @@ -156,6 +157,7 @@ export class ChapterDetailComponent implements OnInit { private readonly filterUtilityService = inject(FilterUtilitiesService); private readonly destroyRef = inject(DestroyRef); private readonly readingListService = inject(ReadingListService); + protected readonly utilityService = inject(UtilityService); protected readonly AgeRating = AgeRating; @@ -316,4 +318,5 @@ export class ChapterDetailComponent implements OnInit { } protected readonly TabId = TabId; + protected readonly Breakpoint = Breakpoint; } diff --git a/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.ts b/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.ts index 9e0ab81bb..057f36b4f 100644 --- a/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.ts +++ b/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.ts @@ -3,7 +3,7 @@ import {CblConflictReasonPipe} from "../../../_pipes/cbl-conflict-reason.pipe"; import {CblImportResultPipe} from "../../../_pipes/cbl-import-result.pipe"; import {FileUploadComponent, FileUploadValidators} from "@iplab/ngx-file-upload"; import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms"; -import {NgForOf, NgIf, NgTemplateOutlet} from "@angular/common"; +import {NgTemplateOutlet} from "@angular/common"; import { NgbAccordionBody, NgbAccordionButton, @@ -11,7 +11,6 @@ import { NgbAccordionDirective, NgbAccordionHeader, NgbAccordionItem, - NgbActiveModal } from "@ng-bootstrap/ng-bootstrap"; import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe"; import {StepTrackerComponent, TimelineStep} from "../step-tracker/step-tracker.component"; @@ -133,7 +132,7 @@ export class ImportCblComponent { formData.append('cbl', files[i]); formData.append('dryRun', 'true'); formData.append('comicVineMatching', this.cblSettingsForm.get('comicVineMatching')?.value + ''); - pages.push(this.readingListService.validateCbl(formData)); + pages.push(this.readingListService.validateCbl(formData, true, this.cblSettingsForm.get('comicVineMatching')?.value as boolean)); } forkJoin(pages).subscribe(results => { @@ -225,7 +224,7 @@ export class ImportCblComponent { formData.append('cbl', files[i]); formData.append('dryRun', 'true'); formData.append('comicVineMatching', this.cblSettingsForm.get('comicVineMatching')?.value + ''); - pages.push(this.readingListService.importCbl(formData)); + pages.push(this.readingListService.importCbl(formData, true, this.cblSettingsForm.get('comicVineMatching')?.value as boolean)); } forkJoin(pages).subscribe(results => { results.forEach(cblImport => { @@ -250,7 +249,7 @@ export class ImportCblComponent { formData.append('cbl', files[i]); formData.append('dryRun', 'false'); formData.append('comicVineMatching', this.cblSettingsForm.get('comicVineMatching')?.value + ''); - pages.push(this.readingListService.importCbl(formData)); + pages.push(this.readingListService.importCbl(formData, false, this.cblSettingsForm.get('comicVineMatching')?.value as boolean)); } forkJoin(pages).subscribe(results => { diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html index 4507aa758..51552a12d 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html @@ -117,7 +117,8 @@
{{t('characters-title')}}
- + {{item.name}} +
diff --git a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.html b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.html index 3f33f42bb..008be179d 100644 --- a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.html +++ b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.html @@ -1,29 +1,29 @@
- - - @if (hasUserRated) { - {{userRating * 20}} - } @else { - N/A - } + [popoverTitle]="t('kavita-tooltip')" [popoverClass]="utilityService.getActiveBreakpoint() > Breakpoint.Mobile ? 'md-popover' : 'lg-popover'"> + + + @if (hasUserRated) { + {{userRating * 20}} + } @else { + N/A + } - @if (overallRating > 0) { - + {{overallRating}} - } - @if (hasUserRated || overallRating > 0) { - % - } - + @if (overallRating > 0) { + + {{overallRating}} + } + @if (hasUserRated || overallRating > 0) { + % + } +
@for (rating of ratings; track rating.provider + rating.averageScore) {
- + {{rating.averageScore}}%
@@ -32,6 +32,15 @@
+ +
+ @for(link of webLinks; track link) { + + + + } +
diff --git a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts index 109828ba5..86880f77c 100644 --- a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts +++ b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts @@ -20,11 +20,13 @@ import {ThemeService} from "../../../_services/theme.service"; import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service"; import {ImageComponent} from "../../../shared/image/image.component"; import {TranslocoDirective} from "@jsverse/transloco"; +import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe"; +import {ImageService} from "../../../_services/image.service"; @Component({ selector: 'app-external-rating', standalone: true, - imports: [CommonModule, ProviderImagePipe, NgOptimizedImage, NgbRating, NgbPopover, LoadingComponent, ProviderNamePipe, NgxStarsModule, ImageComponent, TranslocoDirective], + imports: [CommonModule, ProviderImagePipe, NgOptimizedImage, NgbRating, NgbPopover, LoadingComponent, ProviderNamePipe, NgxStarsModule, ImageComponent, TranslocoDirective, SafeHtmlPipe], templateUrl: './external-rating.component.html', styleUrls: ['./external-rating.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, @@ -37,6 +39,8 @@ export class ExternalRatingComponent implements OnInit { private readonly themeService = inject(ThemeService); public readonly utilityService = inject(UtilityService); public readonly destroyRef = inject(DestroyRef); + public readonly imageService = inject(ImageService); + protected readonly Breakpoint = Breakpoint; @Input({required: true}) seriesId!: number; @@ -44,6 +48,7 @@ export class ExternalRatingComponent implements OnInit { @Input({required: true}) hasUserRated!: boolean; @Input({required: true}) libraryType!: LibraryType; @Input({required: true}) ratings: Array = []; + @Input() webLinks: Array = []; isLoading: boolean = false; overallRating: number = -1; diff --git a/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.html b/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.html index 73eb16942..71c36a4df 100644 --- a/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.html +++ b/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.html @@ -1,28 +1,32 @@
@if (entity.publishers.length > 0) { - {{entity.publishers[0].name}} +
+ +
{{entity.publishers[0].name}}
+
} + @if (libraryType === LibraryType.Book || libraryType === LibraryType.LightNovel) { + {{t('words-count', {num: readingTimeEntity.wordCount | compactNumber})}} + } @else { + {{t('pages-count', {num: readingTimeEntity.pages | compactNumber})}} + } + @if (hasReadingProgress && readingTimeLeft && readingTimeLeft.avgHours !== 0) { - - + + {{readingTimeLeft | readTimeLeft }} } @else { - - + + {{readingTimeEntity | readTime }} } - - @if (libraryType === LibraryType.Book || libraryType === LibraryType.LightNovel) { - {{t('words-count', {num: readingTimeEntity.wordCount | compactNumber})}} - } @else { - {{t('pages-count', {num: readingTimeEntity.pages | compactNumber})}} - }
diff --git a/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.scss b/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.scss index e69de29bb..c3e64f0d7 100644 --- a/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.scss +++ b/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.scss @@ -0,0 +1,20 @@ +.publisher-img-container { + background-color: var(--card-bg-color); + border-radius: 3px; + padding: 2px 5px; + font-size: 0.8rem; + vertical-align: middle; + + div { + min-height: 32px; + line-height: 32px; + } +} + +.time-left{ + font-size: 0.8rem; +} + +.word-count { + font-size: 0.8rem; +} \ No newline at end of file diff --git a/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.ts b/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.ts index d328ee7e7..af3843165 100644 --- a/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.ts +++ b/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; +import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core'; import {AgeRatingImageComponent} from "../../../_single-modules/age-rating-image/age-rating-image.component"; import {CompactNumberPipe} from "../../../_pipes/compact-number.pipe"; import {ReadTimeLeftPipe} from "../../../_pipes/read-time-left.pipe"; @@ -10,6 +10,11 @@ import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {IHasReadingTime} from "../../../_models/common/i-has-reading-time"; import {TranslocoDirective} from "@jsverse/transloco"; import {LibraryType} from "../../../_models/library/library"; +import {ImageComponent} from "../../../shared/image/image.component"; +import {ImageService} from "../../../_services/image.service"; +import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service"; +import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison"; +import {FilterField} from "../../../_models/metadata/v2/filter-field"; @Component({ selector: 'app-metadata-detail-row', @@ -20,13 +25,18 @@ import {LibraryType} from "../../../_models/library/library"; ReadTimeLeftPipe, ReadTimePipe, NgbTooltip, - TranslocoDirective + TranslocoDirective, + ImageComponent ], templateUrl: './metadata-detail-row.component.html', styleUrl: './metadata-detail-row.component.scss', changeDetection: ChangeDetectionStrategy.OnPush }) export class MetadataDetailRowComponent { + protected readonly imageService = inject(ImageService); + private readonly filterUtilityService = inject(FilterUtilitiesService); + + protected readonly LibraryType = LibraryType; @Input({required: true}) entity!: IHasCast; @Input({required: true}) readingTimeEntity!: IHasReadingTime; @@ -35,5 +45,11 @@ export class MetadataDetailRowComponent { @Input({required: true}) ageRating: AgeRating = AgeRating.Unknown; @Input({required: true}) libraryType!: LibraryType; - protected readonly LibraryType = LibraryType; + openGeneric(queryParamName: FilterField, filter: string | number) { + if (queryParamName === FilterField.None) return; + this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter}`).subscribe(); + } + + + protected readonly FilterField = FilterField; } diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html index 189ae9064..074c60ad9 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html @@ -61,7 +61,8 @@ [ratings]="ratings" [userRating]="series.userRating" [hasUserRated]="series.hasUserRated" - [libraryType]="libraryType"> + [libraryType]="libraryType" + [webLinks]="WebLinks"> @@ -71,9 +72,9 @@
@@ -127,89 +128,23 @@
- +
-
+
{{t('writers-title')}}
- + {{item.name}} - @if (!last) { - , - }
-
- {{t('cover-artists-title')}} -
- - - {{item.name}} - @if (!last) { - , - } - - -
-
-
-
- -
-
-
- {{t('genres-title')}} -
- - - {{item.title}} - @if (!last) { - , - } - - -
-
- -
- {{t('tags-title')}} -
- - - {{item.title}} - @if (!last) { - , - } - - -
-
-
-
- - -
-
-
- {{t('weblinks-title')}} -
- @for(link of WebLinks; track link) { - - - - } @empty { - {{null | defaultValue}} - } -
-
-
{{t('publication-status-title')}}
@@ -225,6 +160,68 @@
+
+
+
+ {{t('genres-title')}} +
+ + + {{item.title}} + + +
+
+ +
+ {{t('tags-title')}} +
+ + + {{item.title}} + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@@ -513,7 +510,7 @@ {{t(TabID.Details)}} @defer (when activeTabId === TabID.Details; prefetch on idle) { - + } diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.scss b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.scss index 88835f221..7cd23c73c 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.scss +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.scss @@ -39,3 +39,14 @@ background-color: var(--primary-color-dark-shade); } } + +.upper-details { + font-size: 0.9rem; +} + +@media (max-width: 768px) { + .carousel-tabs-container { + mask-image: linear-gradient(transparent, black 0%, black 90%, transparent 100%); + -webkit-mask-image: linear-gradient(to right, transparent, black 0%, black 90%, transparent 100%); + } +} diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index 1c7d800b2..533efe3cc 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -580,7 +580,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.actionService.scanSeries(series); break; case(Action.RefreshMetadata): - this.actionService.refreshSeriesMetadata(series); + this.actionService.refreshSeriesMetadata(series, undefined, true); break; case(Action.GenerateColorScape): this.actionService.refreshSeriesMetadata(series, undefined, false); diff --git a/UI/Web/src/app/shared/_services/utility.service.ts b/UI/Web/src/app/shared/_services/utility.service.ts index 3adfa15ab..94c5cf696 100644 --- a/UI/Web/src/app/shared/_services/utility.service.ts +++ b/UI/Web/src/app/shared/_services/utility.service.ts @@ -6,7 +6,8 @@ import { MangaFormat } from 'src/app/_models/manga-format'; import { PaginatedResult } from 'src/app/_models/pagination'; import { Series } from 'src/app/_models/series'; import { Volume } from 'src/app/_models/volume'; -import {TranslocoService} from "@jsverse/transloco"; +import {translate, TranslocoService} from "@jsverse/transloco"; +import {debounceTime, ReplaySubject, shareReplay} from "rxjs"; export enum KEY_CODES { RIGHT_ARROW = 'ArrowRight', @@ -37,9 +38,10 @@ export enum Breakpoint { }) export class UtilityService { - mangaFormatKeys: string[] = []; + public readonly activeBreakpointSource = new ReplaySubject(1); + public readonly activeBreakpoint$ = this.activeBreakpointSource.asObservable().pipe(debounceTime(60), shareReplay({bufferSize: 1, refCount: true})); - constructor(private translocoService: TranslocoService) { } + mangaFormatKeys: string[] = []; sortChapters = (a: Chapter, b: Chapter) => { @@ -68,16 +70,16 @@ export class UtilityService { switch(libraryType) { case LibraryType.Book: case LibraryType.LightNovel: - return this.translocoService.translate('common.book-num' + extra) + (includeSpace ? ' ' : ''); + return translate('common.book-num' + extra) + (includeSpace ? ' ' : ''); case LibraryType.Comic: case LibraryType.ComicVine: if (includeHash) { - return this.translocoService.translate('common.issue-hash-num'); + return translate('common.issue-hash-num'); } - return this.translocoService.translate('common.issue-num' + extra) + (includeSpace ? ' ' : ''); + return translate('common.issue-num' + extra) + (includeSpace ? ' ' : ''); case LibraryType.Images: case LibraryType.Manga: - return this.translocoService.translate('common.chapter-num' + extra) + (includeSpace ? ' ' : ''); + return translate('common.chapter-num' + extra) + (includeSpace ? ' ' : ''); } } diff --git a/UI/Web/src/app/shared/badge-expander/badge-expander.component.html b/UI/Web/src/app/shared/badge-expander/badge-expander.component.html index 775ee2cda..e0f5efb7e 100644 --- a/UI/Web/src/app/shared/badge-expander/badge-expander.component.html +++ b/UI/Web/src/app/shared/badge-expander/badge-expander.component.html @@ -3,13 +3,16 @@
@for(item of visibleItems; track item; let i = $index; let last = $last) { + @if (!last) { + , + } } @empty { {{null | defaultValue}} } @if (!isCollapsed && itemsLeft !== 0) { - + }
diff --git a/UI/Web/src/app/shared/badge-expander/badge-expander.component.ts b/UI/Web/src/app/shared/badge-expander/badge-expander.component.ts index c75cea9da..0f7e6550d 100644 --- a/UI/Web/src/app/shared/badge-expander/badge-expander.component.ts +++ b/UI/Web/src/app/shared/badge-expander/badge-expander.component.ts @@ -26,6 +26,7 @@ export class BadgeExpanderComponent implements OnInit { @Input() items: Array = []; @Input() itemsTillExpander: number = 4; + @Input() allowToggle: boolean = true; @ContentChild('badgeExpanderItem') itemTemplate!: TemplateRef; @@ -42,6 +43,8 @@ export class BadgeExpanderComponent implements OnInit { } toggleVisible() { + if (!this.allowToggle) return; + this.isCollapsed = !this.isCollapsed; this.visibleItems = this.items; this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/shared/image/image.component.ts b/UI/Web/src/app/shared/image/image.component.ts index d30d24214..6098f7740 100644 --- a/UI/Web/src/app/shared/image/image.component.ts +++ b/UI/Web/src/app/shared/image/image.component.ts @@ -15,7 +15,6 @@ import {CoverUpdateEvent} from 'src/app/_models/events/cover-update-event'; import {ImageService} from 'src/app/_services/image.service'; import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import {CommonModule, NgOptimizedImage} from "@angular/common"; import {LazyLoadImageModule, StateChange} from "ng-lazyload-image"; /** @@ -24,7 +23,7 @@ import {LazyLoadImageModule, StateChange} from "ng-lazyload-image"; @Component({ selector: 'app-image', standalone: true, - imports: [CommonModule, NgOptimizedImage, LazyLoadImageModule], + imports: [LazyLoadImageModule], templateUrl: './image.component.html', styleUrls: ['./image.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -62,10 +61,13 @@ export class ImageComponent implements OnChanges { */ @Input() styles: {[key: string]: string} = {}; @Input() errorImage: string = this.imageService.errorImage; + /** + * If the image load fails, instead of showing an error image, hide the image (visibility) + */ + @Input() hideOnError: boolean = false; @ViewChild('img', {static: true}) imgElem!: ElementRef; - constructor() { this.hubService.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => { if (!this.processEvents) return; @@ -138,6 +140,9 @@ export class ImageComponent implements OnChanges { // The image could not be loaded for some reason. // `event.data` is the error in this case this.renderer.removeClass(image, 'fade-in'); + if (this.hideOnError) { + this.renderer.addClass(image, 'd-none'); + } this.cdRef.markForCheck(); break; case 'finally': diff --git a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts index 451bbc5dd..e1499577f 100644 --- a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts +++ b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts @@ -106,16 +106,22 @@ export class PreferenceNavComponent implements AfterViewInit { new SideNavItem(SettingsTabId.General, [Role.Admin]), new SideNavItem(SettingsTabId.Media, [Role.Admin]), new SideNavItem(SettingsTabId.Email, [Role.Admin]), - new SideNavItem(SettingsTabId.Statistics, [Role.Admin]), - new SideNavItem(SettingsTabId.System, [Role.Admin]), - + new SideNavItem(SettingsTabId.Users, [Role.Admin]), + new SideNavItem(SettingsTabId.Libraries, [Role.Admin]), + new SideNavItem(SettingsTabId.Tasks, [Role.Admin]), ] }, { - title: 'manage-section-title', + title: 'import-section-title', children: [ - new SideNavItem(SettingsTabId.Users, [Role.Admin]), - new SideNavItem(SettingsTabId.Libraries, [Role.Admin]), + new SideNavItem(SettingsTabId.CBLImport, []), + ] + }, + { + title: 'info-section-title', + children: [ + new SideNavItem(SettingsTabId.System, [Role.Admin]), + new SideNavItem(SettingsTabId.Statistics, [Role.Admin]), new SideNavItem(SettingsTabId.MediaIssues, [Role.Admin], this.accountService.currentUser$.pipe( take(1), @@ -132,13 +138,6 @@ export class PreferenceNavComponent implements AfterViewInit { } }) )), - new SideNavItem(SettingsTabId.Tasks, [Role.Admin]), - ] - }, - { - title: 'import-section-title', - children: [ - new SideNavItem(SettingsTabId.CBLImport, []), ] }, { diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.html b/UI/Web/src/app/volume-detail/volume-detail.component.html index 8d6a95468..eb0c4c701 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.html +++ b/UI/Web/src/app/volume-detail/volume-detail.component.html @@ -7,7 +7,7 @@
- @if (volume.pagesRead < volume.pages && hasReadingProgress) { + @if (volume.pagesRead < volume.pages && volume.pagesRead > 0) {
@@ -20,13 +20,13 @@
{{t('volume-num')}} - +
@@ -49,8 +49,8 @@
@@ -59,7 +59,7 @@
@@ -85,7 +85,7 @@
- +
@@ -96,9 +96,6 @@ {{item.name}} - @if (!last) { - , - }
@@ -109,9 +106,6 @@ {{item.name}} - @if (!last) { - , - }
diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.ts b/UI/Web/src/app/volume-detail/volume-detail.component.ts index a78e7d6b7..1a28721d8 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.ts +++ b/UI/Web/src/app/volume-detail/volume-detail.component.ts @@ -59,7 +59,7 @@ import {ImageComponent} from "../shared/image/image.component"; import {CardItemComponent} from "../cards/card-item/card-item.component"; import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller"; import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service"; -import {UtilityService} from "../shared/_services/utility.service"; +import {Breakpoint, UtilityService} from "../shared/_services/utility.service"; import {ChapterCardComponent} from "../cards/chapter-card/chapter-card.component"; import {DefaultValuePipe} from "../_pipes/default-value.pipe"; import { @@ -196,7 +196,6 @@ export class VolumeDetailComponent implements OnInit { volume: Volume | null = null; series: Series | null = null; libraryType: LibraryType | null = null; - hasReadingProgress = false; activeTabId = TabID.Chapters; readingLists: ReadingList[] = []; @@ -458,4 +457,5 @@ export class VolumeDetailComponent implements OnInit { } } + protected readonly Breakpoint = Breakpoint; } diff --git a/UI/Web/src/assets/images/ExternalServices/AniList-lg.png b/UI/Web/src/assets/images/ExternalServices/AniList-lg.png new file mode 100644 index 000000000..96a2831f9 Binary files /dev/null and b/UI/Web/src/assets/images/ExternalServices/AniList-lg.png differ diff --git a/UI/Web/src/assets/images/ExternalServices/GoogleBooks-lg.png b/UI/Web/src/assets/images/ExternalServices/GoogleBooks-lg.png new file mode 100644 index 000000000..8ae5dd2c8 Binary files /dev/null and b/UI/Web/src/assets/images/ExternalServices/GoogleBooks-lg.png differ diff --git a/UI/Web/src/assets/images/ExternalServices/MAL-lg.png b/UI/Web/src/assets/images/ExternalServices/MAL-lg.png new file mode 100644 index 000000000..e53938459 Binary files /dev/null and b/UI/Web/src/assets/images/ExternalServices/MAL-lg.png differ diff --git a/UI/Web/src/assets/images/logo-64.png b/UI/Web/src/assets/images/logo-64.png new file mode 100644 index 000000000..728c62484 Binary files /dev/null and b/UI/Web/src/assets/images/logo-64.png differ diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index d017fe9d2..8716a10ba 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -227,7 +227,7 @@ "title": "Age Rating Restriction", "description": "When selected, all series and reading lists that have at least one item that is greater than the selected restriction will be pruned from results.", "not-applicable-for-admins": "This is not applicable for admins.", - "age-rating-label": "Age Rating", + "age-rating-label": "{{metadata-fields.age-rating-title}}", "no-restriction": "No Restriction", "include-unknowns-label": "Include Unknowns", "include-unknowns-tooltip": "If true, Unknowns will be allowed with Age Restriction. This could lead to untagged media leaking to users with Age restrictions." @@ -848,7 +848,9 @@ "publishers-title": "Publishers", "imprints-title": "Imprints", "teams-title": "Teams", - "locations-title": "Locations" + "locations-title": "Locations", + "language-title": "Language", + "age-rating-title": "Age Rating" }, "download-button": { @@ -857,7 +859,8 @@ }, "external-rating": { - "entry-label": "See Details" + "entry-label": "See Details", + "kavita-tooltip": "Your Rating + Overall" }, "badge-expander": { @@ -1052,21 +1055,22 @@ }, "details-tab": { - "writers-title": "{{series-metadata-detail.writers-title}}", - "publishers-title": "{{series-metadata-detail.publishers-title}}", - "characters-title": "{{series-metadata-detail.characters-title}}", - "translators-title": "{{series-metadata-detail.translators-title}}", - "letterers-title": "{{series-metadata-detail.letterers-title}}", - "colorists-title": "{{series-metadata-detail.colorists-title}}", - "inkers-title": "{{series-metadata-detail.inkers-title}}", - "pencillers-title": "{{series-metadata-detail.pencillers-title}}", - "cover-artists-title": "{{series-metadata-detail.cover-artists-title}}", - "editors-title": "{{series-metadata-detail.editors-title}}", - "teams-title": "{{series-metadata-detail.teams-title}}", - "locations-title": "{{series-metadata-detail.locations-title}}", - "imprints-title": "{{series-metadata-detail.imprints-title}}", - "genres-title": "{{series-metadata-detail.genres-title}}", - "tags-title": "{{series-metadata-detail.tags-title}}" + "writers-title": "{{metadata-fields.writers-title}}", + "publishers-title": "{{metadata-fields.publishers-title}}", + "characters-title": "{{metadata-fields.characters-title}}", + "translators-title": "{{metadata-fields.translators-title}}", + "letterers-title": "{{metadata-fields.letterers-title}}", + "colorists-title": "{{metadata-fields.colorists-title}}", + "inkers-title": "{{metadata-fields.inkers-title}}", + "pencillers-title": "{{metadata-fields.pencillers-title}}", + "cover-artists-title": "{{metadata-fields.cover-artists-title}}", + "editors-title": "{{metadata-fields.editors-title}}", + "teams-title": "{{metadata-fields.teams-title}}", + "locations-title": "{{metadata-fields.locations-title}}", + "imprints-title": "{{metadata-fields.imprints-title}}", + "genres-title": "{{metadata-fields.genres-title}}", + "tags-title": "{{metadata-fields.tags-title}}", + "weblinks-title": "{{tabs.weblink-tab}}" }, "related-tab": { @@ -1075,18 +1079,18 @@ "chapter-metadata-detail": { "no-data": "No metadata available", - "writers-title": "{{series-metadata-detail.writers-title}}", - "publishers-title": "{{series-metadata-detail.publishers-title}}", - "characters-title": "{{series-metadata-detail.characters-title}}", - "translators-title": "{{series-metadata-detail.translators-title}}", - "letterers-title": "{{series-metadata-detail.letterers-title}}", - "colorists-title": "{{series-metadata-detail.colorists-title}}", - "inkers-title": "{{series-metadata-detail.inkers-title}}", - "pencillers-title": "{{series-metadata-detail.pencillers-title}}", - "cover-artists-title": "{{series-metadata-detail.cover-artists-title}}", - "editors-title": "{{series-metadata-detail.editors-title}}", - "teams-title": "{{series-metadata-detail.teams-title}}", - "locations-title": "{{series-metadata-detail.locations-title}}" + "writers-title": "{{metadata-fields.writers-title}}", + "publishers-title": "{{metadata-fields.publishers-title}}", + "characters-title": "{{metadata-fields.characters-title}}", + "translators-title": "{{metadata-fields.translators-title}}", + "letterers-title": "{{metadata-fields.letterers-title}}", + "colorists-title": "{{metadata-fields.colorists-title}}", + "inkers-title": "{{metadata-fields.inkers-title}}", + "pencillers-title": "{metadata-fields.pencillers-title}}", + "cover-artists-title": "{{metadata-fields.cover-artists-title}}", + "editors-title": "{{metadata-fields.editors-title}}", + "teams-title": "{{metadata-fields.teams-title}}", + "locations-title": "{{metadata-fields.locations-title}}" }, "cover-image-chooser": { @@ -1120,11 +1124,11 @@ }, "entity-info-cards": { - "tags-title": "{{series-metadata-detail.tags-title}}", - "characters-title": "{{series-metadata-detail.characters-title}}", + "tags-title": "{{metadata-fields.tags-title}}", + "characters-title": "{{metadata-fields.characters-title}}", "release-date-title": "Release", "release-date-tooltip": "Release Date", - "age-rating-title": "Age Rating", + "age-rating-title": "{{metadata-fields.age-rating-title}}", "length-title": "Length", "pages-count": "{{num}} Pages", "words-count": "{{num}} Words", @@ -1133,7 +1137,7 @@ "date-added-title": "Date Added", "size-title": "Size", "id-title": "ID", - "links-title": "{{series-metadata-detail.links-title}}", + "links-title": "{{metadata-fields.links-title}}", "isbn-title": "ISBN", "sort-order-title": "Sort Order", "last-read-title": "Last Read", @@ -1148,7 +1152,7 @@ "release-date-title": "{{entity-info-cards.release-date-title}}", "release-year-tooltip": "Release Year", "age-rating-title": "{{entity-info-cards.age-rating-title}}", - "language-title": "Language", + "language-title": "{{metadata-fields.language-title}}", "publication-status-title": "Publication", "publication-status-tooltip": "Publication Status", "scrobbling-title": "Scrobbling", @@ -1514,7 +1518,7 @@ "settings": { "account-section-title": "Account", "server-section-title": "Server", - "manage-section-title": "Manage", + "info-section-title": "Info", "import-section-title": "Import", "kavitaplus-section-title": "{{settings.admin-kavitaplus}}", "admin-general": "General", @@ -1601,7 +1605,7 @@ "read-options-alt": "Read options", "incognito-alt": "(Incognito)", "no-data": "Nothing added", - "characters-title": "{{series-metadata-detail.characters-title}}" + "characters-title": "{{metadata-fields.characters-title}}" }, "events-widget": { @@ -1854,8 +1858,8 @@ "read": "Read", "in-progress": "In Progress", "rating-label": "Rating", - "age-rating-label": "Age Rating", - "language-label": "Language", + "age-rating-label": "{{metadata-fields.age-rating-title}}", + "language-label": "{{metadata-fields.language-title}}", "publication-status-label": "Publication Status", "series-name-label": "Series Name", "series-name-tooltip": "Series name will filter against Name, Sort Name, or Localized Name", @@ -1895,21 +1899,21 @@ "genres-label": "{{metadata-fields.genres-title}}", "tags-label": "{{metadata-fields.tags-title}}", - "cover-artist-label": "Cover Artist", - "writer-label": "Writer", - "publisher-label": "Publisher", - "imprint-label": "Imprint", - "penciller-label": "Penciller", - "letterer-label": "Letterer", - "inker-label": "Inker", - "editor-label": "Editor", - "colorist-label": "Colorist", - "character-label": "Character", - "translator-label": "Translator", - "team-label": "{{filter-field-pipe.team}}", - "location-label": "{{filter-field-pipe.location}}", - "language-label": "Language", - "age-rating-label": "Age Rating", + "cover-artist-label": "{{metadata-fields.cover-artists-title}}", + "writer-label": "{{metadata-fields.writers-title}}", + "publisher-label": "{{metadata-fields.publishers-title}}", + "imprint-label": "{{metadata-fields.imprints-title}}", + "penciller-label": "{{metadata-fields.pencillers-title}}", + "letterer-label": "{{metadata-fields.letterers-title}}", + "inker-label": "{{metadata-fields.inkers-title}}", + "editor-label": "{{metadata-fields.editors-title}}", + "colorist-label": "{{metadata-fields.colorists-title}}", + "character-label": "{{metadata-fields.characters-title}}", + "translator-label": "{{metadata-fields.translators-title}}", + "team-label": "{{metadata-fields.teams-title}}", + "location-label": "{{metadata-fields.locations-title}}", + "language-label": "{{metadata-fields.language-title}}", + "age-rating-label": "{{metadata-fields.age-rating-title}}", "publication-status-label": "Publication Status", "required-field": "{{validation.required-field}}", @@ -1920,7 +1924,7 @@ "summary-label": "Summary", "release-year-label": "Release Year", "web-link-description": "Here you can add many different links to external services.", - "web-link-label": "Web Link", + "web-link-label": "{{tabs.weblink-tab}}", "cover-image-description": "Upload and choose a new cover image. Press Save to upload and override the cover.", "save": "{{common.save}}", "field-locked-alt": "Field is locked", @@ -1937,6 +1941,7 @@ "lowest-folder-path-tooltip": "Lowest path from library root that contains all series files", "publication-status-title": "Publication Status", "total-pages-title": "Total Pages", + "total-words-title": "Total Words", "total-items-title": "Total Items", "max-items-title": "Max Items", "size-title": "Size", @@ -1953,7 +1958,8 @@ "force-refresh": "Force Refresh", "force-refresh-tooltip": "Force refresh external metadata from Kavita+", "loose-leaf-volume": "Loose Leaf Chapters", - "specials-volume": "Specials" + "specials-volume": "Specials", + "release-year-validation": "{{validation.year-validation}}" }, "edit-chapter-modal": { @@ -2253,7 +2259,7 @@ }, "filter-field-pipe": { - "age-rating": "Age Rating", + "age-rating": "{{metadata-fields.age-rating-title}}", "characters": "{{metadata-fields.characters-title}}", "collection-tags": "Collection Tags", "colorist": "Colorist", @@ -2477,7 +2483,6 @@ "import-mal-stack": "Import MAL Stack", "import-mal-stack-tooltip": "Creates a Smart Collection from your MAL Interest Stacks", "read": "Read", - "read-tooltip": "", "customize": "Customize", "customize-tooltip": "TODO", "mark-visible": "Mark as Visible", @@ -2531,7 +2536,8 @@ "validation": { "required-field": "This field is required", "valid-email": "This must be a valid email", - "password-validation": "Password must be between 6 and 32 characters in length" + "password-validation": "Password must be between 6 and 32 characters in length", + "year-validation": "This must be a valid year greater than 1000 and 4 characters long" }, "entity-type": { diff --git a/UI/Web/src/styles.scss b/UI/Web/src/styles.scss index 3e7e2cca4..aceec40a2 100644 --- a/UI/Web/src/styles.scss +++ b/UI/Web/src/styles.scss @@ -87,7 +87,6 @@ label, select, .clickable { app-root { background-color: transparent; scrollbar-width: thin; - scrollbar-color: var(--primary-color-scrollbar); } ::-webkit-scrollbar { @@ -96,7 +95,6 @@ app-root { ::-webkit-scrollbar-thumb { background-clip: padding-box; - background-color: var(--primary-color-scrollbar); border: 3px solid transparent; border-radius: 8px; min-height: 50px; @@ -109,6 +107,6 @@ body { .setting-section-break { height: 1px; - background-color: rgba(255, 255, 255, 0.2); + background-color: var(--setting-break-color); margin: 30px 0; -} \ No newline at end of file +} diff --git a/UI/Web/src/theme/components/_nav.scss b/UI/Web/src/theme/components/_nav.scss index 058d3b220..195ca8bc1 100644 --- a/UI/Web/src/theme/components/_nav.scss +++ b/UI/Web/src/theme/components/_nav.scss @@ -22,8 +22,12 @@ } .nav-tabs { - border-color: var(--elevation-layer9); - + border-color: transparent; + .nav-item { + border-color: var(--elevation-layer9); + border-width: 0 0 1px 0; + border-style: solid; + } .nav-link { color: var(--nav-link-text-color); position: relative; diff --git a/UI/Web/src/theme/themes/dark.scss b/UI/Web/src/theme/themes/dark.scss index 2e20b7eb5..6c340c979 100644 --- a/UI/Web/src/theme/themes/dark.scss +++ b/UI/Web/src/theme/themes/dark.scss @@ -88,6 +88,7 @@ --h6-text-color: #d5d5d5; --h6-font-size: 1.2rem; --h6-font-weight: bold; + --setting-break-color: rgba(255, 255, 255, 0.2); /* Table */ @@ -170,7 +171,7 @@ --nav-tab-hover-border-color: var(--primary-color); --nav-tab-active-text-color: white; --nav-tab-border-hover-color: transparent; - --nav-tab-hover-text-color: white + --nav-tab-hover-text-color: white; --nav-tab-hover-bg-color: transparent; --nav-tab-border-top: transparent; --nav-tab-border-left: transparent; @@ -332,7 +333,7 @@ --card-border-radius: 5px; --card-progress-bar-color: var(--primary-color); --card-overlay-bg-color: rgba(0, 0, 0, 0); - --card-overlay-hover-bg-color: rgba(0, 0, 0, 0.2); + --card-overlay-hover-bg-color: rgba(0, 0, 0, 0.4); --card-progress-triangle-size: 28px; /* Slider */ diff --git a/openapi.json b/openapi.json index 1755983cd..206975b7d 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.1", "info": { "title": "Kavita", - "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.2.4", + "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.2.5", "license": { "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" @@ -1120,7 +1120,19 @@ "Cbl" ], "summary": "The first step in a cbl import. This validates the cbl file that if an import occured, would it be successful.\r\nIf this returns errors, the cbl will always be rejected by Kavita.", + "parameters": [ + { + "name": "comicVineMatching", + "in": "query", + "description": "Use comic vine matching or not. Defaults to false", + "schema": { + "type": "boolean", + "default": false + } + } + ], "requestBody": { + "description": "FormBody with parameter name of cbl", "content": { "multipart/form-data": { "schema": { @@ -1129,19 +1141,12 @@ "cbl": { "type": "string", "format": "binary" - }, - "comicVineMatching": { - "type": "boolean", - "default": false } } }, "encoding": { "cbl": { "style": "form" - }, - "comicVineMatching": { - "style": "form" } } } @@ -1177,7 +1182,28 @@ "Cbl" ], "summary": "Performs the actual import (assuming dryRun = false)", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "If true, will only emulate the import but not perform. This should be done to preview what will happen", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "comicVineMatching", + "in": "query", + "description": "Use comic vine matching or not. Defaults to false", + "schema": { + "type": "boolean", + "default": false + } + } + ], "requestBody": { + "description": "FormBody with parameter name of cbl", "content": { "multipart/form-data": { "schema": { @@ -1186,26 +1212,12 @@ "cbl": { "type": "string", "format": "binary" - }, - "dryRun": { - "type": "boolean", - "default": false - }, - "comicVineMatching": { - "type": "boolean", - "default": false } } }, "encoding": { "cbl": { "style": "form" - }, - "dryRun": { - "style": "form" - }, - "comicVineMatching": { - "style": "form" } } } @@ -2848,6 +2860,37 @@ } } }, + "/api/Image/publisher": { + "get": { + "tags": [ + "Image" + ], + "summary": "Returns the image associated with a publisher", + "parameters": [ + { + "name": "publisherName", + "in": "query", + "description": "", + "schema": { + "type": "string" + } + }, + { + "name": "apiKey", + "in": "query", + "description": "", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/Image/cover-upload": { "get": { "tags": [ @@ -12536,6 +12579,7 @@ ], "summary": "Uploads a new theme file", "requestBody": { + "description": "", "content": { "multipart/form-data": { "schema": { @@ -14964,7 +15008,8 @@ "type": "object", "additionalProperties": { "type": "integer", - "format": "int32" + "format": "int32", + "nullable": true }, "description": "For Double Page reader, this will contain snap points to ensure the reader always resumes on correct page", "nullable": true @@ -15913,7 +15958,8 @@ "type": "object", "additionalProperties": { "type": "integer", - "format": "int32" + "format": "int32", + "nullable": true }, "description": "For Double Page reader, this will contain snap points to ensure the reader always resumes on correct page", "nullable": true @@ -20184,6 +20230,11 @@ "description": "The highest level folder for this Series", "nullable": true }, + "lowestFolderPath": { + "type": "string", + "description": "Lowest path (that is under library root) that contains all files for the series.", + "nullable": true + }, "lastFolderScanned": { "type": "string", "description": "The last time the folder for this series was scanned",