Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo 2024-08-20 19:09:30 -05:00 committed by GitHub
parent 7ca523adef
commit 38fc8e9110
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
103 changed files with 1643 additions and 1079 deletions

4
.gitignore vendored
View File

@ -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/**/*

View File

@ -6,7 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.29" />

View File

@ -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<ILogger<DirectoryService>>(), fileSystem);
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
new MockReadingItemService(new BasicParser(ds, new ImageParser(ds))), Substitute.For<IEventHub>());
new MockReadingItemService(ds, Substitute.For<IBookService>()), Substitute.For<IEventHub>());
// var parsedSeries = new Dictionary<ParsedSeries, IList<ParserInfo>>();
//
@ -239,7 +268,7 @@ public class ParseScannedFilesTests : AbstractDbTest
var fileSystem = CreateTestFilesystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
new MockReadingItemService(new BasicParser(ds, new ImageParser(ds))), Substitute.For<IEventHub>());
new MockReadingItemService(ds, Substitute.For<IBookService>()), Substitute.For<IEventHub>());
var directoriesSeen = new HashSet<string>();
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
@ -259,7 +288,7 @@ public class ParseScannedFilesTests : AbstractDbTest
var fileSystem = CreateTestFilesystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
new MockReadingItemService(new BasicParser(ds, new ImageParser(ds))), Substitute.For<IEventHub>());
new MockReadingItemService(ds, Substitute.For<IBookService>()), Substitute.For<IEventHub>());
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<ILogger<DirectoryService>>(), fileSystem);
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
new MockReadingItemService(new BasicParser(ds, new ImageParser(ds))), Substitute.For<IEventHub>());
new MockReadingItemService(ds, Substitute.For<IBookService>()), Substitute.For<IEventHub>());
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<ILogger<DirectoryService>>(), fileSystem);
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
new MockReadingItemService(new BasicParser(ds, new ImageParser(ds))), Substitute.For<IEventHub>());
new MockReadingItemService(ds, Substitute.For<IBookService>()), Substitute.For<IEventHub>());
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
LibraryIncludes.Folders | LibraryIncludes.FileTypes);

View File

@ -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<Library> 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<ILogger<DirectoryService>>(), new FileSystem());
var mockReadingService = new MockReadingItemService(ds, Substitute.For<IBookService>());
var processSeries = new ProcessSeries(_unitOfWork, Substitute.For<ILogger<ProcessSeries>>(),
Substitute.For<IEventHub>(),
ds, Substitute.For<ICacheHelper>(), mockReadingService, Substitute.For<IFileService>(),
Substitute.For<IMetadataService>(),
Substitute.For<IWordCountAnalyzerService>(), Substitute.For<ICollectionTagService>(),
Substitute.For<IReadingListService>(),
Substitute.For<IExternalMetadataService>(), new TagManagerService(_unitOfWork, Substitute.For<ILogger<TagManagerService>>()));
var scanner = new ScannerService(_unitOfWork, Substitute.For<ILogger<ScannerService>>(),
Substitute.For<IMetadataService>(),
Substitute.For<ICacheService>(), Substitute.For<IEventHub>(), ds,
mockReadingService, processSeries, Substitute.For<IWordCountAnalyzerService>());
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<LibraryType>(libraryTypeString, out var libraryType))
{
throw new ArgumentException($"'{libraryTypeString}' is not a valid LibraryType");
}
return (publisher, libraryType);
}
private async Task<string> 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<List<string>>(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<string> 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("<?xml version='1.0' encoding='utf-8'?>");
comicInfo.AppendLine("<ComicInfo>");
// 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)}</{tagName}>");
}
}
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("</ComicInfo>");
return comicInfo.ToString();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -1,5 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<ComicInfo>
<Series>Accel World</Series>
<Number>2</Number>
</ComicInfo>

View File

@ -1,6 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<ComicInfo>
<Series>Hajime no Ippo</Series>
<Number>3</Number>
<AgeRating>M</AgeRating>
</ComicInfo>

View File

@ -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.

View File

@ -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"
]

View File

@ -56,7 +56,7 @@
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="MailKit" Version="4.7.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@ -70,20 +70,20 @@
<PackageReference Include="Hangfire.InMemory" Version="0.10.3" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.62" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.63" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.8" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
<PackageReference Include="NetVips" Version="2.4.1" />
<PackageReference Include="NetVips.Native" Version="8.15.2" />
<PackageReference Include="NetVips.Native" Version="8.15.3" />
<PackageReference Include="NReco.Logging.File" Version="1.2.1" />
<PackageReference Include="Serilog" Version="4.0.1" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
@ -96,15 +96,15 @@
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.37.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.31.0.96804">
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.32.0.97167">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.1" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
<PackageReference Include="System.IO.Abstractions" Version="21.0.29" />
<PackageReference Include="System.Drawing.Common" Version="8.0.7" />
<PackageReference Include="System.Drawing.Common" Version="8.0.8" />
<PackageReference Include="VersOne.Epub" Version="3.3.2" />
</ItemGroup>

View File

@ -34,7 +34,7 @@ public class CblController : BaseApiController
/// <param name="comicVineMatching">Use comic vine matching or not. Defaults to false</param>
/// <returns></returns>
[HttpPost("validate")]
public async Task<ActionResult<CblImportSummaryDto>> ValidateCbl(IFormFile cbl, [FromForm] bool comicVineMatching = false)
public async Task<ActionResult<CblImportSummaryDto>> ValidateCbl(IFormFile cbl, [FromQuery] bool comicVineMatching = false)
{
var userId = User.GetUserId();
try
@ -85,7 +85,7 @@ public class CblController : BaseApiController
/// <param name="comicVineMatching">Use comic vine matching or not. Defaults to false</param>
/// <returns></returns>
[HttpPost("import")]
public async Task<ActionResult<CblImportSummaryDto>> ImportCbl(IFormFile cbl, [FromForm] bool dryRun = false, [FromForm] bool comicVineMatching = false)
public async Task<ActionResult<CblImportSummaryDto>> ImportCbl(IFormFile cbl, [FromQuery] bool dryRun = false, [FromQuery] bool comicVineMatching = false)
{
try
{

View File

@ -242,6 +242,46 @@ public class ImageController : BaseApiController
return PhysicalFile(file.FullName, MimeTypeMap.GetMimeType(format), Path.GetFileName(file.FullName));
}
/// <summary>
/// Returns the image associated with a publisher
/// </summary>
/// <param name="publisherName"></param>
/// <param name="apiKey"></param>
/// <returns></returns>
[HttpGet("publisher")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = ["publisherName", "apiKey"])]
public async Task<ActionResult> 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));
}
/// <summary>
/// Returns a temp coverupload image
/// </summary>

View File

@ -34,5 +34,4 @@ public enum LibraryType
/// </summary>
[Description("Comic (Comic Vine)")]
ComicVine = 5,
}

View File

@ -29,6 +29,7 @@ public interface IDirectoryService
string LocalizationDirectory { get; }
string CustomizedTemplateDirectory { get; }
string TemplateDirectory { get; }
string PublisherDirectory { get; }
/// <summary>
/// Original BookmarkDirectory. Only used for resetting directory. Use <see cref="ServerSettingKey.BackupDirectory"/> for actual path.
/// </summary>
@ -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<DirectoryService> _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);
}
/// <summary>

View File

@ -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<ILibraryWatcher>();
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)

View File

@ -67,6 +67,7 @@ public interface IImageService
Task<string> ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat);
Task<bool> IsImage(string filePath);
Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat);
Task<string> 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<string> 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<Vector3> SortByBrightness(List<Vector3> colors)
{
return colors.OrderBy(c => 0.299 * c.X + 0.587 * c.Y + 0.114 * c.Z).ToList();
}
public static List<Vector3> SortByVibrancy(List<Vector3> colors)
private static List<Vector3> SortByVibrancy(List<Vector3> colors)
{
return colors.OrderByDescending(c =>
{
@ -686,10 +727,10 @@ public class ImageService : IImageService
};
}
private static string FallbackToKavitaReaderFavicon(string baseUrl)
private static async Task<string> 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<string> 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;
}
/// <inheritdoc />
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<string> coverImages, CoverImageSize size, string dest)
{
@ -891,4 +971,6 @@ public class ImageService : IImageService
return Color.FromArgb(r, g, b);
}
}

View File

@ -14,7 +14,7 @@
<PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.31.0.96804">
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.32.0.97167">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@ -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;

View File

@ -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`;
}
}

View File

@ -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;

View File

@ -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();
}

View File

@ -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}`;
}

View File

@ -106,12 +106,12 @@ export class ReadingListService {
return this.httpClient.get<boolean>(this.baseUrl + 'readinglist/name-exists?name=' + name);
}
validateCbl(form: FormData) {
return this.httpClient.post<CblImportSummary>(this.baseUrl + 'cbl/validate', form);
validateCbl(form: FormData, dryRun: boolean, useComicVineMatching: boolean) {
return this.httpClient.post<CblImportSummary>(this.baseUrl + `cbl/validate?dryRun=${dryRun}&useComicVineMatching=${useComicVineMatching}`, form);
}
importCbl(form: FormData) {
return this.httpClient.post<CblImportSummary>(this.baseUrl + 'cbl/import', form);
importCbl(form: FormData, dryRun: boolean, useComicVineMatching: boolean) {
return this.httpClient.post<CblImportSummary>(this.baseUrl + `cbl/import?dryRun=${dryRun}&useComicVineMatching=${useComicVineMatching}`, form);
}
getCharacters(readingListId: number) {

View File

@ -20,6 +20,17 @@
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="webLinks" [title]="t('weblinks-title')">
<ng-template #carouselItem let-item>
<a class="me-1" [href]="item | safeHtml" target="_blank" rel="noopener noreferrer" [title]="item">
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(item)"
[errorImage]="imageService.errorWebLinkImage"></app-image>
</a>
</ng-template>
</app-carousel-reel>
</div>
@if (genres.length > 0 || tags.length > 0) {
<div class="setting-section-break" aria-hidden="true"></div>
}

View File

@ -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<Genre> = [];
@Input() tags: Array<Tag> = [];
@Input() webLinks: Array<string> = [];
openPerson(queryParamName: FilterField, filter: Person) {

View File

@ -10,7 +10,7 @@
<div class="offcanvas-body">
<ng-container *ngIf="CoverUrl as coverUrl">
<div style="width: 160px" class="mx-auto mb-3">
<app-image *ngIf="coverUrl" height="230px" width="160px" [styles]="{'object-fit': 'contain', 'max-height': '230px'}" [imageUrl]="coverUrl"></app-image>
<app-image *ngIf="coverUrl" height="232.91px" width="160px" [styles]="{'object-fit': 'contain', 'max-height': '232.91px'}" [imageUrl]="coverUrl"></app-image>
</div>
</ng-container>

View File

@ -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;

View File

@ -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 {

View File

@ -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);

View File

@ -1,4 +1,4 @@
$image-height: 230px;
$image-height: 232.91px;
$image-width: 160px;
.card-img-top {

View File

@ -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) {

View File

@ -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);

View File

@ -60,7 +60,7 @@
<div class="card-title-container">
<span class="card-title" id="{{volume.id}}" tabindex="0">
<a class="dark-exempt btn-icon" routerLink="/library/{{libraryId}}/series/{{seriesId}}/chapter/{{volume.id}}">
<a class="dark-exempt btn-icon" routerLink="/library/{{libraryId}}/series/{{seriesId}}/volume/{{volume.id}}">
{{volume.name}}
</a>
</span>

View File

@ -7,7 +7,7 @@
<div class="image-container col-5 col-sm-6 col-md-5 col-lg-5 col-xl-2 col-xxl-2 col-xxxl-2 d-none d-sm-block mb-3 position-relative">
<app-image [styles]="{'object-fit': 'contain', 'background': 'none', 'max-height': '400px'}" [imageUrl]="coverImage"></app-image>
@if (chapter.pagesRead < chapter.pages && hasReadingProgress) {
@if (chapter.pagesRead < chapter.pages && chapter.pagesRead > 0) {
<div class="progress-banner" ngbTooltip="{{(chapter.pagesRead / chapter.pages) * 100 | number:'1.0-1'}}%">
<ngb-progressbar type="primary" [value]="chapter.pagesRead" [max]="chapter.pages" [showValue]="true"></ngb-progressbar>
</div>
@ -41,7 +41,7 @@
<app-metadata-detail-row [entity]="chapter"
[ageRating]="chapter.ageRating"
[hasReadingProgress]="hasReadingProgress"
[hasReadingProgress]="chapter.pagesRead > 0"
[readingTimeEntity]="chapter"
[libraryType]="libraryType">
</app-metadata-detail-row>
@ -64,8 +64,8 @@
<div class="btn-group">
<button type="button" class="btn btn-primary-outline" (click)="read()">
<span>
<i class="fa {{hasReadingProgress ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;{{(hasReadingProgress) ? t('continue') : t('read')}}</span>
<i class="fa {{chapter.pagesRead > 0 ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;{{(chapter.pagesRead > 0) ? t('continue') : t('read')}}</span>
</span>
</button>
<div class="btn-group" ngbDropdown role="group" display="dynamic" [attr.aria-label]="t('read-options-alt')">
@ -74,7 +74,7 @@
<button ngbDropdownItem (click)="read(true)">
<span>
<i class="fa fa-glasses" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;{{(hasReadingProgress) ? t('continue-incognito') : t('read-incognito')}}</span>
<span class="read-btn--text">&nbsp;{{(chapter.pagesRead > 0) ? t('continue-incognito') : t('read-incognito')}}</span>
</span>
</button>
</div>
@ -100,7 +100,7 @@
</div>
<div class="mt-2 mb-3">
<app-read-more [text]="chapter.summary || ''"></app-read-more>
<app-read-more [text]="chapter.summary || ''" [maxLength]="utilityService.getActiveBreakpoint() >= Breakpoint.Desktop ? 585 : 250"></app-read-more>
</div>
<div class="mt-2">
@ -111,9 +111,6 @@
<app-badge-expander [items]="chapter.writers">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.Writers, item.id)">{{item.name}}</a>
@if (!last) {
,
}
</ng-template>
</app-badge-expander>
</div>
@ -124,9 +121,6 @@
<app-badge-expander [items]="chapter.coverArtists">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.CoverArtist, item.id)">{{item.name}}</a>
@if (!last) {
,
}
</ng-template>
</app-badge-expander>
</div>

View File

@ -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;
}

View File

@ -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 => {

View File

@ -117,7 +117,8 @@
<h5>{{t('characters-title')}}</h5>
<app-badge-expander [items]="characters">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item" (click)="goToCharacter(item)"></app-person-badge>
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="goToCharacter(item)">{{item.name}}</a>
<!-- <app-person-badge a11y-click="13,32" class="col-auto" [person]="item" (click)="goToCharacter(item)"></app-person-badge>-->
</ng-template>
</app-badge-expander>
</div>

View File

@ -1,29 +1,29 @@
<ng-container *transloco="let t; read: 'external-rating'">
<div class="row g-0">
<div class="col-auto custom-col clickable" [ngbPopover]="popContent"
popoverTitle="Your Rating + Overall" [popoverClass]="utilityService.getActiveBreakpoint() > Breakpoint.Mobile ? 'md-popover' : 'lg-popover'">
<span class="badge rounded-pill ps-0 me-1">
<app-image classes="me-1" imageUrl="assets/images/logo-32.png" width="24px" height="24px" />
@if (hasUserRated) {
{{userRating * 20}}
} @else {
N/A
}
[popoverTitle]="t('kavita-tooltip')" [popoverClass]="utilityService.getActiveBreakpoint() > Breakpoint.Mobile ? 'md-popover' : 'lg-popover'">
<span class="badge rounded-pill ps-0 me-1">
<app-image classes="me-1" imageUrl="assets/images/logo-32.png" width="24px" height="24px" />
@if (hasUserRated) {
{{userRating * 20}}
} @else {
N/A
}
@if (overallRating > 0) {
+ {{overallRating}}
}
@if (hasUserRated || overallRating > 0) {
%
}
</span>
@if (overallRating > 0) {
+ {{overallRating}}
}
@if (hasUserRated || overallRating > 0) {
%
}
</span>
</div>
@for (rating of ratings; track rating.provider + rating.averageScore) {
<div class="col-auto custom-col clickable" [ngbPopover]="externalPopContent" [popoverContext]="{rating: rating}"
[popoverTitle]="rating.provider | providerName" popoverClass="sm-popover">
<span class="badge rounded-pill me-1">
<img class="me-1" [ngSrc]="rating.provider | providerImage" width="24" height="24" alt="" aria-hidden="true">
<img class="me-1" [ngSrc]="rating.provider | providerImage:true" width="24" height="24" alt="" aria-hidden="true">
{{rating.averageScore}}%
</span>
</div>
@ -32,6 +32,15 @@
<div class="col-auto" style="padding-top: 8px">
<app-loading [loading]="isLoading" size="spinner-border-sm"></app-loading>
</div>
<div class="col-auto ms-2">
@for(link of webLinks; track link) {
<a class="me-1" [href]="link | safeHtml" target="_blank" rel="noopener noreferrer" [title]="link">
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(link)"
[errorImage]="imageService.errorWebLinkImage"></app-image>
</a>
}
</div>
</div>
<ng-template #popContent>

View File

@ -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<Rating> = [];
@Input() webLinks: Array<string> = [];
isLoading: boolean = false;
overallRating: number = -1;

View File

@ -1,28 +1,32 @@
<ng-container *transloco="let t; read: 'series-detail'">
<div class="mt-2 mb-2">
@if (entity.publishers.length > 0) {
<span class="me-2">{{entity.publishers[0].name}}</span>
<div class="publisher-img-container d-inline-flex align-items-center me-2 position-relative">
<app-image [imageUrl]="imageService.getPublisherImage(entity.publishers[0].name)" [classes]="'me-2'" [hideOnError]="true" width="32px" height="32px"
aria-hidden="true"></app-image>
<div class="position-relative d-inline-block" (click)="openGeneric(FilterField.Publisher, entity.publishers[0].id)">{{entity.publishers[0].name}}</div>
</div>
}
<span class="me-2">
<app-age-rating-image [rating]="ageRating"></app-age-rating-image>
</span>
@if (libraryType === LibraryType.Book || libraryType === LibraryType.LightNovel) {
<span class="word-count me-3">{{t('words-count', {num: readingTimeEntity.wordCount | compactNumber})}}</span>
} @else {
<span class="word-count me-3">{{t('pages-count', {num: readingTimeEntity.pages | compactNumber})}}</span>
}
@if (hasReadingProgress && readingTimeLeft && readingTimeLeft.avgHours !== 0) {
<span [ngbTooltip]="t('time-left-alt')">
<i class="fa-solid fa-clock me-1" aria-hidden="true"></i>
<span class="time-left" [ngbTooltip]="t('time-left-alt')">
<i class="fa-solid fa-clock" aria-hidden="true"></i>
{{readingTimeLeft | readTimeLeft }}
</span>
} @else {
<span [ngbTooltip]="t('time-to-read-alt')">
<i class="fa-regular fa-clock me-1" aria-hidden="true"></i>
<span class="time-left" [ngbTooltip]="t('time-to-read-alt')">
<i class="fa-regular fa-clock" aria-hidden="true"></i>
{{readingTimeEntity | readTime }}
</span>
}
<span class="ms-2 me-2"></span>
@if (libraryType === LibraryType.Book || libraryType === LibraryType.LightNovel) {
<span>{{t('words-count', {num: readingTimeEntity.wordCount | compactNumber})}}</span>
} @else {
<span>{{t('pages-count', {num: readingTimeEntity.pages | compactNumber})}}</span>
}
</div>
</ng-container>

View File

@ -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;
}

View File

@ -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;
}

View File

@ -61,7 +61,8 @@
[ratings]="ratings"
[userRating]="series.userRating"
[hasUserRated]="series.hasUserRated"
[libraryType]="libraryType">
[libraryType]="libraryType"
[webLinks]="WebLinks">
</app-external-rating>
</div>
@ -71,9 +72,9 @@
<div class="btn-group">
<button type="button" class="btn btn-primary-outline" (click)="read()">
<span>
<i class="fa {{hasReadingProgress ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;{{(hasReadingProgress) ? t('continue') : t('read')}}</span>
</span>
<i class="fa {{hasReadingProgress ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;{{(hasReadingProgress) ? t('continue') : t('read')}}</span>
</span>
</button>
<div class="btn-group" ngbDropdown role="group" display="dynamic" [attr.aria-label]="t('read-options-alt')">
<button type="button" class="btn btn-primary-outline dropdown-toggle-split" ngbDropdownToggle></button>
@ -127,89 +128,23 @@
</div>
<div class="mt-2 mb-3">
<app-read-more [text]="seriesMetadata.summary || ''" [maxLength]="utilityService.getActiveBreakpoint() >= Breakpoint.Desktop ? 1000 : 250"></app-read-more>
<app-read-more [text]="seriesMetadata.summary || ''" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 585 : 200"></app-read-more>
</div>
<div class="mt-2">
<div class="mt-2 upper-details">
<div class="row g-0">
<div class="col-6">
<span class="fw-bold">{{t('writers-title')}}</span>
<div>
<app-badge-expander [items]="seriesMetadata.writers">
<app-badge-expander [items]="seriesMetadata.writers"
[itemsTillExpander]="3"
[allowToggle]="false">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openFilter(FilterField.Writers, item.id)">{{item.name}}</a>
@if (!last) {
,
}
</ng-template>
</app-badge-expander>
</div>
</div>
<div class="col-6">
<span class="fw-bold">{{t('cover-artists-title')}}</span>
<div>
<app-badge-expander [items]="seriesMetadata.coverArtists">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openFilter(FilterField.CoverArtist, item.id)">{{item.name}}</a>
@if (!last) {
,
}
</ng-template>
</app-badge-expander>
</div>
</div>
</div>
</div>
<div class="mt-3 mb-2">
<div class="row g-0">
<div class="col-6">
<span class="fw-bold">{{t('genres-title')}}</span>
<div>
<app-badge-expander [items]="seriesMetadata.genres" [itemsTillExpander]="5">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openFilter(FilterField.Genres, item.id)">{{item.title}}</a>
@if (!last) {
,
}
</ng-template>
</app-badge-expander>
</div>
</div>
<div class="col-6">
<span class="fw-bold">{{t('tags-title')}}</span>
<div>
<app-badge-expander [items]="seriesMetadata.tags" [itemsTillExpander]="5">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openFilter(FilterField.Tags, item.id)">{{item.title}}</a>
@if (!last) {
,
}
</ng-template>
</app-badge-expander>
</div>
</div>
</div>
</div>
<div class="mt-3 mb-2">
<div class="row g-0">
<div class="col-6">
<span class="fw-bold">{{t('weblinks-title')}}</span>
<div>
@for(link of WebLinks; track link) {
<a class="me-1" [href]="link | safeHtml" target="_blank" rel="noopener noreferrer" [title]="link">
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(link)"
[errorImage]="imageService.errorWebLinkImage"></app-image>
</a>
} @empty {
{{null | defaultValue}}
}
</div>
</div>
<div class="col-6">
<span class="fw-bold">{{t('publication-status-title')}}</span>
<div>
@ -225,6 +160,68 @@
</div>
</div>
<div class="mt-3 mb-2 upper-details">
<div class="row g-0">
<div class="col-6">
<span class="fw-bold">{{t('genres-title')}}</span>
<div>
<app-badge-expander [items]="seriesMetadata.genres"
[itemsTillExpander]="3"
[allowToggle]="false">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openFilter(FilterField.Genres, item.id)">{{item.title}}</a>
</ng-template>
</app-badge-expander>
</div>
</div>
<div class="col-6">
<span class="fw-bold">{{t('tags-title')}}</span>
<div>
<app-badge-expander [items]="seriesMetadata.tags"
[itemsTillExpander]="3"
[allowToggle]="false">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openFilter(FilterField.Tags, item.id)">{{item.title}}</a>
</ng-template>
</app-badge-expander>
</div>
</div>
</div>
</div>
<!-- <div class="mt-3 mb-2">-->
<!-- <div class="row g-0">-->
<!-- <div class="col-6">-->
<!-- <span class="fw-bold">{{t('weblinks-title')}}</span>-->
<!-- <div>-->
<!-- @for(link of WebLinks; track link) {-->
<!-- <a class="me-1" [href]="link | safeHtml" target="_blank" rel="noopener noreferrer" [title]="link">-->
<!-- <app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(link)"-->
<!-- [errorImage]="imageService.errorWebLinkImage"></app-image>-->
<!-- </a>-->
<!-- } @empty {-->
<!-- {{null | defaultValue}}-->
<!-- }-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="col-6">-->
<!-- <span class="fw-bold">{{t('publication-status-title')}}</span>-->
<!-- <div>-->
<!-- @if (seriesMetadata.publicationStatus | publicationStatus; as pubStatus) {-->
<!-- <a class="dark-exempt btn-icon" (click)="openFilter(FilterField.PublicationStatus, seriesMetadata.publicationStatus)"-->
<!-- href="javascript:void(0);"-->
<!-- [ngbTooltip]="t('publication-status-tooltip') + (seriesMetadata.totalCount === 0 ? '' : ' (' + seriesMetadata.maxCount + ' / ' + seriesMetadata.totalCount + ')')">-->
<!-- {{pubStatus}}-->
<!-- </a>-->
<!-- }-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
</div>
</div>
@ -513,7 +510,7 @@
<a ngbNavLink>{{t(TabID.Details)}}</a>
<ng-template ngbNavContent>
@defer (when activeTabId === TabID.Details; prefetch on idle) {
<app-details-tab [metadata]="seriesMetadata" [genres]="seriesMetadata.genres" [tags]="seriesMetadata.tags"></app-details-tab>
<app-details-tab [metadata]="seriesMetadata" [genres]="seriesMetadata.genres" [tags]="seriesMetadata.tags" [webLinks]="WebLinks"></app-details-tab>
}
</ng-template>
</li>

View File

@ -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%);
}
}

View File

@ -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);

View File

@ -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<Breakpoint>(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 ? ' ' : '');
}
}

View File

@ -3,13 +3,16 @@
<div class="content">
@for(item of visibleItems; track item; let i = $index; let last = $last) {
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i, last: last }"></ng-container>
@if (!last) {
<span>, </span>
}
} @empty {
{{null | defaultValue}}
}
@if (!isCollapsed && itemsLeft !== 0) {
<button type="button" class="btn btn-sm btn-outline-primary ms-2" (click)="toggleVisible()" [attr.aria-expanded]="!isCollapsed">
<a href="javascript:void(0);" type="button" class="dark-exempt btn-icon ms-1" (click)="toggleVisible()" [attr.aria-expanded]="!isCollapsed">
{{t('more-items', {count: itemsLeft})}}
</button>
</a>
}
</div>
</div>

View File

@ -26,6 +26,7 @@ export class BadgeExpanderComponent implements OnInit {
@Input() items: Array<any> = [];
@Input() itemsTillExpander: number = 4;
@Input() allowToggle: boolean = true;
@ContentChild('badgeExpanderItem') itemTemplate!: TemplateRef<any>;
@ -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();

View File

@ -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<HTMLImageElement>;
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':

View File

@ -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, []),
]
},
{

View File

@ -7,7 +7,7 @@
<div class="image-container col-5 col-sm-6 col-md-5 col-lg-5 col-xl-2 col-xxl-2 col-xxxl-2 d-none d-sm-block mb-3">
<app-image [styles]="{'object-fit': 'contain', 'background': 'none', 'max-height': '400px'}" [imageUrl]="coverImage"></app-image>
@if (volume.pagesRead < volume.pages && hasReadingProgress) {
@if (volume.pagesRead < volume.pages && volume.pagesRead > 0) {
<div class="progress-banner" ngbTooltip="{{(volume.pagesRead / volume.pages) * 100 | number:'1.0-1'}}%">
<ngb-progressbar type="primary" height="5px" [value]="volume.pagesRead" [max]="volume.pages" [showValue]="true"></ngb-progressbar>
</div>
@ -20,13 +20,13 @@
<div class="subtitle mt-2 mb-2">
<span>
{{t('volume-num')}}
<app-entity-title [libraryType]="libraryType!" [entity]="volume" [prioritizeTitleName]="true"></app-entity-title>
<app-entity-title [libraryType]="libraryType" [entity]="volume" [prioritizeTitleName]="true"></app-entity-title>
</span>
</div>
<app-metadata-detail-row [entity]="volumeCast"
[ageRating]="maxAgeRating"
[hasReadingProgress]="hasReadingProgress"
[hasReadingProgress]="volume.pagesRead > 0"
[readingTimeEntity]="volume"
[libraryType]="libraryType">
</app-metadata-detail-row>
@ -49,8 +49,8 @@
<div class="btn-group">
<button type="button" class="btn btn-primary-outline" (click)="readVolume()">
<span>
<i class="fa {{hasReadingProgress ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;{{(hasReadingProgress) ? t('continue') : t('read')}}</span>
<i class="fa {{volume.pagesRead > 0 ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;{{(volume.pagesRead > 0) ? t('continue') : t('read')}}</span>
</span>
</button>
<div class="btn-group" ngbDropdown role="group" display="dynamic" [attr.aria-label]="t('read-options-alt')">
@ -59,7 +59,7 @@
<button ngbDropdownItem (click)="readVolume(true)">
<span>
<i class="fa fa-glasses" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;{{(hasReadingProgress) ? t('continue-incognito') : t('read-incognito')}}</span>
<span class="read-btn--text">&nbsp;{{(volume.pagesRead > 0) ? t('continue-incognito') : t('read-incognito')}}</span>
</span>
</button>
</div>
@ -85,7 +85,7 @@
</div>
<div class="mt-2 mb-3">
<app-read-more [text]="volume.chapters[0].summary || ''"></app-read-more>
<app-read-more [text]="volume.chapters[0].summary || ''" [maxLength]="utilityService.getActiveBreakpoint() >= Breakpoint.Desktop ? 585 : 250"></app-read-more>
</div>
<div class="mt-2">
@ -96,9 +96,6 @@
<app-badge-expander [items]="volumeCast.writers">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.Writers, item.id)">{{item.name}}</a>
@if (!last) {
,
}
</ng-template>
</app-badge-expander>
</div>
@ -109,9 +106,6 @@
<app-badge-expander [items]="volumeCast.coverArtists">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.CoverArtist, item.id)">{{item.name}}</a>
@if (!last) {
,
}
</ng-template>
</app-badge-expander>
</div>

View File

@ -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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -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": {

View File

@ -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;
}

Some files were not shown because too many files have changed in this diff Show More