diff --git a/.gitignore b/.gitignore index 612917a47b..27f5cb538a 100644 --- a/.gitignore +++ b/.gitignore @@ -536,6 +536,6 @@ UI/Web/.angular/ BenchmarkDotNet.Artifacts -API.Tests/Services/Test Data/ImageService/Covers/*_output* -API.Tests/Services/Test Data/ImageService/Covers/*_baseline* -API.Tests/Services/Test Data/ImageService/Covers/index.html +API.Tests/Services/Test Data/ImageService/**/*_output* +API.Tests/Services/Test Data/ImageService/**/*_baseline* +API.Tests/Services/Test Data/ImageService/**/*.html diff --git a/API.Benchmark/API.Benchmark.csproj b/API.Benchmark/API.Benchmark.csproj index ebc913fe1b..2dcf08f323 100644 --- a/API.Benchmark/API.Benchmark.csproj +++ b/API.Benchmark/API.Benchmark.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index e65229ab54..7ae0e70fbd 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -6,13 +6,13 @@ - + - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/API.Tests/Services/ImageServiceTests.cs b/API.Tests/Services/ImageServiceTests.cs index c868bfce28..ac3c3157f6 100644 --- a/API.Tests/Services/ImageServiceTests.cs +++ b/API.Tests/Services/ImageServiceTests.cs @@ -1,9 +1,14 @@ -using System.IO; +using System.Drawing; +using System.IO; +using System.IO.Abstractions; using System.Linq; using System.Text; using API.Entities.Enums; using API.Services; +using EasyCaching.Core; +using Microsoft.Extensions.Logging; using NetVips; +using NSubstitute; using Xunit; using Image = NetVips.Image; @@ -12,6 +17,7 @@ namespace API.Tests.Services; public class ImageServiceTests { private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ImageService/Covers"); + private readonly string _testDirectoryColorScapes = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ImageService/ColorScapes"); private const string OutputPattern = "_output"; private const string BaselinePattern = "_baseline"; @@ -121,4 +127,98 @@ private void GenerateHtmlFile() File.WriteAllText(Path.Combine(_testDirectory, "index.html"), htmlBuilder.ToString()); } + + [Fact] + public void TestColorScapes() + { + // Step 1: Delete any images that have _output in the name + var outputFiles = Directory.GetFiles(_testDirectoryColorScapes, "*_output.*"); + foreach (var file in outputFiles) + { + File.Delete(file); + } + + // Step 2: Scan the _testDirectory for images + var imageFiles = Directory.GetFiles(_testDirectoryColorScapes, "*.*") + .Where(file => !file.EndsWith("html")) + .Where(file => !file.Contains(OutputPattern) && !file.Contains(BaselinePattern)) + .ToList(); + + // Step 3: Process each image + foreach (var imagePath in imageFiles) + { + var fileName = Path.GetFileNameWithoutExtension(imagePath); + var colors = ImageService.CalculateColorScape(imagePath); + + // Generate primary color image + GenerateColorImage(colors.Primary, Path.Combine(_testDirectoryColorScapes, $"{fileName}_primary_output.png")); + + // Generate secondary color image + GenerateColorImage(colors.Secondary, Path.Combine(_testDirectoryColorScapes, $"{fileName}_secondary_output.png")); + } + + // Step 4: Generate HTML file + GenerateHtmlFileForColorScape(); + + } + + private static void GenerateColorImage(string hexColor, string outputPath) + { + var color = ImageService.HexToRgb(hexColor); + using var colorImage = Image.Black(200, 100); + using var output = colorImage + new[] { color.R / 255.0, color.G / 255.0, color.B / 255.0 }; + output.WriteToFile(outputPath); + } + + private void GenerateHtmlFileForColorScape() + { + var imageFiles = Directory.GetFiles(_testDirectoryColorScapes, "*.*") + .Where(file => !file.EndsWith("html")) + .Where(file => !file.Contains(OutputPattern) && !file.Contains(BaselinePattern)) + .ToList(); + + var htmlBuilder = new StringBuilder(); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine("Color Scape Comparison"); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine("
"); + + foreach (var imagePath in imageFiles) + { + var fileName = Path.GetFileNameWithoutExtension(imagePath); + var primaryPath = Path.Combine(_testDirectoryColorScapes, $"{fileName}_primary_output.png"); + var secondaryPath = Path.Combine(_testDirectoryColorScapes, $"{fileName}_secondary_output.png"); + + htmlBuilder.AppendLine("
"); + htmlBuilder.AppendLine($"

{fileName}

"); + htmlBuilder.AppendLine($"\"{fileName}\""); + if (File.Exists(primaryPath)) + { + htmlBuilder.AppendLine($"\"{fileName}"); + } + if (File.Exists(secondaryPath)) + { + htmlBuilder.AppendLine($"\"{fileName}"); + } + htmlBuilder.AppendLine("
"); + } + + htmlBuilder.AppendLine("
"); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + + File.WriteAllText(Path.Combine(_testDirectoryColorScapes, "colorscape_index.html"), htmlBuilder.ToString()); + } } diff --git a/API.Tests/Services/ReadingListServiceTests.cs b/API.Tests/Services/ReadingListServiceTests.cs index 80e36dadca..96a3effa45 100644 --- a/API.Tests/Services/ReadingListServiceTests.cs +++ b/API.Tests/Services/ReadingListServiceTests.cs @@ -52,7 +52,9 @@ public ReadingListServiceTests() var mapper = config.CreateMapper(); _unitOfWork = new UnitOfWork(_context, mapper, null!); - _readingListService = new ReadingListService(_unitOfWork, Substitute.For>(), Substitute.For()); + var ds = new DirectoryService(Substitute.For>(), new MockFileSystem()); + _readingListService = new ReadingListService(_unitOfWork, Substitute.For>(), + Substitute.For(), Substitute.For(), ds); _readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), diff --git a/API.Tests/Services/Test Data/ImageService/ColorScapes/blue-2.png b/API.Tests/Services/Test Data/ImageService/ColorScapes/blue-2.png new file mode 100644 index 0000000000..8a386b2b87 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/ColorScapes/blue-2.png differ diff --git a/API.Tests/Services/Test Data/ImageService/ColorScapes/blue.jpg b/API.Tests/Services/Test Data/ImageService/ColorScapes/blue.jpg new file mode 100644 index 0000000000..ed53f66496 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/ColorScapes/blue.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/ColorScapes/green-red.png b/API.Tests/Services/Test Data/ImageService/ColorScapes/green-red.png new file mode 100644 index 0000000000..5b3d453867 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/ColorScapes/green-red.png differ diff --git a/API.Tests/Services/Test Data/ImageService/ColorScapes/green.png b/API.Tests/Services/Test Data/ImageService/ColorScapes/green.png new file mode 100644 index 0000000000..8ed6c4fe45 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/ColorScapes/green.png differ diff --git a/API.Tests/Services/Test Data/ImageService/ColorScapes/lightblue-2.png b/API.Tests/Services/Test Data/ImageService/ColorScapes/lightblue-2.png new file mode 100644 index 0000000000..68b71ce396 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/ColorScapes/lightblue-2.png differ diff --git a/API.Tests/Services/Test Data/ImageService/ColorScapes/lightblue.png b/API.Tests/Services/Test Data/ImageService/ColorScapes/lightblue.png new file mode 100644 index 0000000000..9569f80d41 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/ColorScapes/lightblue.png differ diff --git a/API.Tests/Services/Test Data/ImageService/ColorScapes/pink.png b/API.Tests/Services/Test Data/ImageService/ColorScapes/pink.png new file mode 100644 index 0000000000..a629889637 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/ColorScapes/pink.png differ diff --git a/API.Tests/Services/Test Data/ImageService/ColorScapes/yellow-blue.png b/API.Tests/Services/Test Data/ImageService/ColorScapes/yellow-blue.png new file mode 100644 index 0000000000..0a4f36f30c Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/ColorScapes/yellow-blue.png differ diff --git a/API/API.csproj b/API/API.csproj index 3cd3513b80..d257052692 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -55,8 +55,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -70,14 +70,14 @@ - + - - - - + + + + @@ -85,26 +85,26 @@ - - + + - + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - - + + + diff --git a/API/Controllers/ColorScapeController.cs b/API/Controllers/ColorScapeController.cs new file mode 100644 index 0000000000..415f4aad8f --- /dev/null +++ b/API/Controllers/ColorScapeController.cs @@ -0,0 +1,64 @@ +using System.Threading.Tasks; +using API.Data; +using API.DTOs.Theme; +using API.Entities.Interfaces; +using API.Extensions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +[Authorize] +public class ColorScapeController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + + public ColorScapeController(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + /// + /// Returns the color scape for a series + /// + /// + /// + [HttpGet("series")] + public async Task> GetColorScapeForSeries(int id) + { + var entity = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(id, User.GetUserId()); + return GetColorSpaceDto(entity); + } + + /// + /// Returns the color scape for a volume + /// + /// + /// + [HttpGet("volume")] + public async Task> GetColorScapeForVolume(int id) + { + var entity = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(id, User.GetUserId()); + return GetColorSpaceDto(entity); + } + + /// + /// Returns the color scape for a chapter + /// + /// + /// + [HttpGet("chapter")] + public async Task> GetColorScapeForChapter(int id) + { + var entity = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(id); + return GetColorSpaceDto(entity); + } + + + + private ActionResult GetColorSpaceDto(IHasCoverImage entity) + { + if (entity == null) return Ok(ColorScapeDto.Empty); + return Ok(new ColorScapeDto(entity.PrimaryColor, entity.SecondaryColor)); + } +} diff --git a/API/Controllers/DeviceController.cs b/API/Controllers/DeviceController.cs index 61a847b6e4..cfd3c3416b 100644 --- a/API/Controllers/DeviceController.cs +++ b/API/Controllers/DeviceController.cs @@ -7,6 +7,7 @@ using API.Extensions; using API.Services; using API.SignalR; +using AutoMapper; using Kavita.Common; using Microsoft.AspNetCore.Mvc; @@ -24,20 +25,27 @@ public class DeviceController : BaseApiController private readonly IEmailService _emailService; private readonly IEventHub _eventHub; private readonly ILocalizationService _localizationService; + private readonly IMapper _mapper; public DeviceController(IUnitOfWork unitOfWork, IDeviceService deviceService, - IEmailService emailService, IEventHub eventHub, ILocalizationService localizationService) + IEmailService emailService, IEventHub eventHub, ILocalizationService localizationService, IMapper mapper) { _unitOfWork = unitOfWork; _deviceService = deviceService; _emailService = emailService; _eventHub = eventHub; _localizationService = localizationService; + _mapper = mapper; } + /// + /// Creates a new Device + /// + /// + /// [HttpPost("create")] - public async Task CreateOrUpdateDevice(CreateDeviceDto dto) + public async Task> CreateOrUpdateDevice(CreateDeviceDto dto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Devices); if (user == null) return Unauthorized(); @@ -46,20 +54,22 @@ public async Task CreateOrUpdateDevice(CreateDeviceDto dto) var device = await _deviceService.Create(dto, user); if (device == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-device-create")); + + return Ok(_mapper.Map(device)); } catch (KavitaException ex) { return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); } - - - - - return Ok(); } + /// + /// Updates an existing Device + /// + /// + /// [HttpPost("update")] - public async Task UpdateDevice(UpdateDeviceDto dto) + public async Task> UpdateDevice(UpdateDeviceDto dto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Devices); if (user == null) return Unauthorized(); @@ -67,7 +77,7 @@ public async Task UpdateDevice(UpdateDeviceDto dto) if (device == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-device-update")); - return Ok(); + return Ok(_mapper.Map(device)); } /// diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index b7212c7f3b..646a720cd8 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -25,15 +25,18 @@ public class ImageController : BaseApiController private readonly IDirectoryService _directoryService; private readonly IImageService _imageService; private readonly ILocalizationService _localizationService; + private readonly IReadingListService _readingListService; /// public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService, - IImageService imageService, ILocalizationService localizationService) + IImageService imageService, ILocalizationService localizationService, + IReadingListService readingListService) { _unitOfWork = unitOfWork; _directoryService = directoryService; _imageService = imageService; _localizationService = localizationService; + _readingListService = readingListService; } /// @@ -42,7 +45,7 @@ public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryServic /// /// [HttpGet("chapter-cover")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"chapterId", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["chapterId", "apiKey"])] public async Task GetChapterCoverImage(int chapterId, string apiKey) { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); @@ -60,7 +63,7 @@ public async Task GetChapterCoverImage(int chapterId, string apiKe /// /// [HttpGet("library-cover")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"libraryId", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["libraryId", "apiKey"])] public async Task GetLibraryCoverImage(int libraryId, string apiKey) { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); @@ -78,7 +81,7 @@ public async Task GetLibraryCoverImage(int libraryId, string apiKe /// /// [HttpGet("volume-cover")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"volumeId", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["volumeId", "apiKey"])] public async Task GetVolumeCoverImage(int volumeId, string apiKey) { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); @@ -95,7 +98,7 @@ public async Task GetVolumeCoverImage(int volumeId, string apiKey) /// /// Id of Series /// - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"seriesId", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["seriesId", "apiKey"])] [HttpGet("series-cover")] public async Task GetSeriesCoverImage(int seriesId, string apiKey) { @@ -116,7 +119,7 @@ public async Task GetSeriesCoverImage(int seriesId, string apiKey) /// /// [HttpGet("collection-cover")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"collectionTagId", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["collectionTagId", "apiKey"])] public async Task GetCollectionCoverImage(int collectionTagId, string apiKey) { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); @@ -141,15 +144,17 @@ public async Task GetCollectionCoverImage(int collectionTagId, str /// /// [HttpGet("readinglist-cover")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"readingListId", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["readingListId", "apiKey"])] public async Task GetReadingListCoverImage(int readingListId, string apiKey) { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); if (userId == 0) return BadRequest(); + var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ReadingListRepository.GetCoverImageAsync(readingListId)); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) { - var destFile = await GenerateReadingListCoverImage(readingListId); + var destFile = await _readingListService.GenerateReadingListCoverImage(readingListId); if (string.IsNullOrEmpty(destFile)) return BadRequest(await _localizationService.Translate(userId, "no-cover-image")); return PhysicalFile(destFile, MimeTypeMap.GetMimeType(_directoryService.FileSystem.Path.GetExtension(destFile)), _directoryService.FileSystem.Path.GetFileName(destFile)); } @@ -158,22 +163,6 @@ public async Task GetReadingListCoverImage(int readingListId, stri return PhysicalFile(path, MimeTypeMap.GetMimeType(format), _directoryService.FileSystem.Path.GetFileName(path)); } - private async Task GenerateReadingListCoverImage(int readingListId) - { - var covers = await _unitOfWork.ReadingListRepository.GetRandomCoverImagesAsync(readingListId); - var destFile = _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, - ImageService.GetReadingListFormat(readingListId)); - var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - destFile += settings.EncodeMediaAs.GetExtension(); - - if (_directoryService.FileSystem.File.Exists(destFile)) return destFile; - ImageService.CreateMergedImage( - covers.Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList(), - settings.CoverImageSize, - destFile); - return !_directoryService.FileSystem.File.Exists(destFile) ? string.Empty : destFile; - } - private async Task GenerateCollectionCoverImage(int collectionId) { var covers = await _unitOfWork.CollectionTagRepository.GetRandomCoverImagesAsync(collectionId); @@ -186,6 +175,7 @@ private async Task GenerateCollectionCoverImage(int collectionId) covers.Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList(), settings.CoverImageSize, destFile); + // TODO: Refactor this so that collections have a dedicated cover image so we can calculate primary/secondary colors return !_directoryService.FileSystem.File.Exists(destFile) ? string.Empty : destFile; } @@ -198,7 +188,8 @@ private async Task GenerateCollectionCoverImage(int collectionId) /// API Key for user. Needed to authenticate request /// [HttpGet("bookmark")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"chapterId", "pageNum", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["chapterId", "pageNum", "apiKey" + ])] public async Task GetBookmarkImage(int chapterId, int pageNum, string apiKey) { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); @@ -220,7 +211,7 @@ public async Task GetBookmarkImage(int chapterId, int pageNum, str /// /// [HttpGet("web-link")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = new []{"url", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = ["url", "apiKey"])] public async Task GetWebLinkImage(string url, string apiKey) { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); @@ -258,7 +249,7 @@ public async Task GetWebLinkImage(string url, string apiKey) /// [Authorize(Policy="RequireAdminRole")] [HttpGet("cover-upload")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"filename", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["filename", "apiKey"])] public async Task GetCoverUploadImage(string filename, string apiKey) { if (await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey) == 0) return BadRequest(); diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 509f8fda35..6d0f7e8dde 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -471,6 +471,7 @@ public async Task GetCollections(string apiKey) var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{apiKey}/collections", apiKey, prefix); SetFeedId(feed, "collections"); + feed.Entries.AddRange(tags.Select(tag => new FeedEntry() { Id = tag.Id.ToString(), @@ -539,6 +540,8 @@ public async Task GetReadingLists(string apiKey, [FromQuery] int var feed = CreateFeed("All Reading Lists", $"{apiKey}/reading-list", apiKey, prefix); SetFeedId(feed, "reading-list"); + AddPagination(feed, readingLists, $"{prefix}{apiKey}/reading-list/"); + foreach (var readingListDto in readingLists) { feed.Entries.Add(new FeedEntry() @@ -555,6 +558,7 @@ public async Task GetReadingLists(string apiKey, [FromQuery] int }); } + return CreateXmlResult(SerializeXml(feed)); } @@ -1014,7 +1018,7 @@ private static ContentResult CreateXmlResult(string xml) }; } - private static void AddPagination(Feed feed, PagedList list, string href) + private static void AddPagination(Feed feed, PagedList list, string href) { var url = href; if (href.Contains('?')) diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 54a1adfb34..159c8c922b 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -748,7 +748,7 @@ public async Task UnBookmarkPage(BookmarkDto bookmarkDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); if (user == null) return new UnauthorizedResult(); - if (user.Bookmarks.IsNullOrEmpty()) return Ok(); + if (user.Bookmarks == null || user.Bookmarks.Count == 0) return Ok(); if (!await _accountService.HasBookmarkPermission(user)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-permission")); diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 802edebf27..8193204690 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -210,9 +210,13 @@ public async Task> CheckHowOutOfDate() /// Pull the Changelog for Kavita from Github and display /// /// + [AllowAnonymous] [HttpGet("changelog")] public async Task>> GetChangelog() { + // Strange bug where [Authorize] doesn't work + if (User.GetUserId() == 0) return Unauthorized(); + return Ok(await _versionUpdaterService.GetAllReleases()); } diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 79e20d422e..af6b9a7ccf 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using System.Threading.Tasks; using API.Constants; using API.Data; @@ -109,6 +108,7 @@ public async Task UploadSeriesCoverImageFromUrl(UploadFileDto uplo { series.CoverImage = filePath; series.CoverImageLocked = true; + _imageService.UpdateColorScape(series); _unitOfWork.SeriesRepository.Update(series); } @@ -157,6 +157,7 @@ public async Task UploadCollectionCoverImageFromUrl(UploadFileDto { tag.CoverImage = filePath; tag.CoverImageLocked = true; + _imageService.UpdateColorScape(tag); _unitOfWork.CollectionTagRepository.Update(tag); } @@ -208,6 +209,7 @@ public async Task UploadReadingListCoverImageFromUrl(UploadFileDto { readingList.CoverImage = filePath; readingList.CoverImageLocked = true; + _imageService.UpdateColorScape(readingList); _unitOfWork.ReadingListRepository.Update(readingList); } @@ -327,15 +329,18 @@ public async Task UploadVolumeCoverImageFromUrl(UploadFileDto uplo { chapter.CoverImage = filePath; chapter.CoverImageLocked = true; + _imageService.UpdateColorScape(chapter); _unitOfWork.ChapterRepository.Update(chapter); volume.CoverImage = chapter.CoverImage; + _imageService.UpdateColorScape(volume); _unitOfWork.VolumeRepository.Update(volume); } if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(chapter.VolumeId, MessageFactoryEntityTypes.Volume), false); await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, @@ -391,6 +396,7 @@ await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, if (!string.IsNullOrEmpty(filePath)) { library.CoverImage = filePath; + _imageService.UpdateColorScape(library); _unitOfWork.LibraryRepository.Update(library); } @@ -426,12 +432,15 @@ public async Task ResetChapterLock(UploadFileDto uploadFileDto) var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id); if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); var originalFile = chapter.CoverImage; + chapter.CoverImage = string.Empty; chapter.CoverImageLocked = false; _unitOfWork.ChapterRepository.Update(chapter); + var volume = (await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId))!; volume.CoverImage = chapter.CoverImage; _unitOfWork.VolumeRepository.Update(volume); + var series = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId))!; if (_unitOfWork.HasChanges()) @@ -451,7 +460,4 @@ public async Task ResetChapterLock(UploadFileDto uploadFileDto) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reset-chapter-lock")); } - - - } diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index aad00565c8..b346ca57be 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -10,7 +10,7 @@ namespace API.DTOs; /// A Chapter is the lowest grouping of a reading medium. A Chapter contains a set of MangaFiles which represents the underlying /// file (abstracted from type). /// -public class ChapterDto : IHasReadTimeEstimate +public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage { public int Id { get; init; } /// @@ -159,4 +159,8 @@ public class ChapterDto : IHasReadTimeEstimate public int TotalCount { get; set; } #endregion + + public string CoverImage { get; set; } + public string PrimaryColor { get; set; } + public string SecondaryColor { get; set; } } diff --git a/API/DTOs/Collection/AppUserCollectionDto.cs b/API/DTOs/Collection/AppUserCollectionDto.cs index f01cd492c9..fd6f21e744 100644 --- a/API/DTOs/Collection/AppUserCollectionDto.cs +++ b/API/DTOs/Collection/AppUserCollectionDto.cs @@ -1,11 +1,12 @@ using System; using API.Entities.Enums; +using API.Entities.Interfaces; using API.Services.Plus; namespace API.DTOs.Collection; #nullable enable -public class AppUserCollectionDto +public class AppUserCollectionDto : IHasCoverImage { public int Id { get; init; } public string Title { get; set; } = default!; @@ -17,6 +18,9 @@ public class AppUserCollectionDto /// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set. /// public string? CoverImage { get; set; } = string.Empty; + + public string PrimaryColor { get; set; } + public string SecondaryColor { get; set; } public bool CoverImageLocked { get; set; } /// diff --git a/API/DTOs/ColorScape.cs b/API/DTOs/ColorScape.cs new file mode 100644 index 0000000000..39d1446dd3 --- /dev/null +++ b/API/DTOs/ColorScape.cs @@ -0,0 +1,10 @@ +namespace API.DTOs; + +/// +/// A primary and secondary color +/// +public class ColorScape +{ + public required string? Primary { get; set; } + public required string? Secondary { get; set; } +} diff --git a/API/DTOs/MediaErrors/MediaErrorDto.cs b/API/DTOs/MediaErrors/MediaErrorDto.cs index d890108d29..bfaf571246 100644 --- a/API/DTOs/MediaErrors/MediaErrorDto.cs +++ b/API/DTOs/MediaErrors/MediaErrorDto.cs @@ -20,4 +20,6 @@ public class MediaErrorDto /// Exception message /// public string Details { get; set; } + + public DateTime CreatedUtc { get; set; } } diff --git a/API/DTOs/ReadingLists/ReadingListDto.cs b/API/DTOs/ReadingLists/ReadingListDto.cs index f4961ac273..a16d288711 100644 --- a/API/DTOs/ReadingLists/ReadingListDto.cs +++ b/API/DTOs/ReadingLists/ReadingListDto.cs @@ -1,9 +1,10 @@ using System; +using API.Entities.Interfaces; namespace API.DTOs.ReadingLists; #nullable enable -public class ReadingListDto +public class ReadingListDto : IHasCoverImage { public int Id { get; init; } public string Title { get; set; } = default!; @@ -17,6 +18,10 @@ public class ReadingListDto /// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set. /// public string? CoverImage { get; set; } = string.Empty; + + public string PrimaryColor { get; set; } + public string SecondaryColor { get; set; } + /// /// Minimum Year the Reading List starts /// diff --git a/API/DTOs/SeriesDto.cs b/API/DTOs/SeriesDto.cs index a8ec37d9c9..e4dfcf3036 100644 --- a/API/DTOs/SeriesDto.cs +++ b/API/DTOs/SeriesDto.cs @@ -5,7 +5,7 @@ namespace API.DTOs; #nullable enable -public class SeriesDto : IHasReadTimeEstimate +public class SeriesDto : IHasReadTimeEstimate, IHasCoverImage { public int Id { get; init; } public string? Name { get; init; } @@ -62,4 +62,8 @@ public class SeriesDto : IHasReadTimeEstimate /// The last time the folder for this series was scanned /// public DateTime LastFolderScanned { get; set; } + + public string? CoverImage { get; set; } + public string PrimaryColor { get; set; } + public string SecondaryColor { get; set; } } diff --git a/API/DTOs/Theme/ColorScapeDto.cs b/API/DTOs/Theme/ColorScapeDto.cs new file mode 100644 index 0000000000..066e87d845 --- /dev/null +++ b/API/DTOs/Theme/ColorScapeDto.cs @@ -0,0 +1,19 @@ +namespace API.DTOs.Theme; +#nullable enable + +/// +/// A set of colors for the color scape system in the UI +/// +public class ColorScapeDto +{ + public string? Primary { get; set; } + public string? Secondary { get; set; } + + public ColorScapeDto(string? primary, string? secondary) + { + Primary = primary; + Secondary = secondary; + } + + public static readonly ColorScapeDto Empty = new ColorScapeDto(null, null); +} diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 5fe4f297b8..d1cddf2809 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -174,4 +174,6 @@ public class UserPreferencesDto /// [Required] public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None; + + } diff --git a/API/DTOs/VolumeDto.cs b/API/DTOs/VolumeDto.cs index 5822f5d6f8..ffb72ff6a6 100644 --- a/API/DTOs/VolumeDto.cs +++ b/API/DTOs/VolumeDto.cs @@ -8,7 +8,7 @@ namespace API.DTOs; -public class VolumeDto : IHasReadTimeEstimate +public class VolumeDto : IHasReadTimeEstimate, IHasCoverImage { public int Id { get; set; } /// @@ -62,4 +62,8 @@ public bool IsSpecial() { return MinNumber.Is(Parser.SpecialVolumeNumber); } + + public string CoverImage { get; set; } + public string PrimaryColor { get; set; } + public string SecondaryColor { get; set; } } diff --git a/API/Data/Migrations/20240808100353_CoverPrimaryColors.Designer.cs b/API/Data/Migrations/20240808100353_CoverPrimaryColors.Designer.cs new file mode 100644 index 0000000000..d105ece925 --- /dev/null +++ b/API/Data/Migrations/20240808100353_CoverPrimaryColors.Designer.cs @@ -0,0 +1,3079 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20240808100353_CoverPrimaryColors")] + partial class CoverPrimaryColors + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.7"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20240808100353_CoverPrimaryColors.cs b/API/Data/Migrations/20240808100353_CoverPrimaryColors.cs new file mode 100644 index 0000000000..c69c906b0e --- /dev/null +++ b/API/Data/Migrations/20240808100353_CoverPrimaryColors.cs @@ -0,0 +1,138 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class CoverPrimaryColors : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PrimaryColor", + table: "Volume", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SecondaryColor", + table: "Volume", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "PrimaryColor", + table: "Series", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SecondaryColor", + table: "Series", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "PrimaryColor", + table: "ReadingList", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SecondaryColor", + table: "ReadingList", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "PrimaryColor", + table: "Library", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SecondaryColor", + table: "Library", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "PrimaryColor", + table: "Chapter", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SecondaryColor", + table: "Chapter", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "PrimaryColor", + table: "AppUserCollection", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SecondaryColor", + table: "AppUserCollection", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PrimaryColor", + table: "Volume"); + + migrationBuilder.DropColumn( + name: "SecondaryColor", + table: "Volume"); + + migrationBuilder.DropColumn( + name: "PrimaryColor", + table: "Series"); + + migrationBuilder.DropColumn( + name: "SecondaryColor", + table: "Series"); + + migrationBuilder.DropColumn( + name: "PrimaryColor", + table: "ReadingList"); + + migrationBuilder.DropColumn( + name: "SecondaryColor", + table: "ReadingList"); + + migrationBuilder.DropColumn( + name: "PrimaryColor", + table: "Library"); + + migrationBuilder.DropColumn( + name: "SecondaryColor", + table: "Library"); + + migrationBuilder.DropColumn( + name: "PrimaryColor", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "SecondaryColor", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "PrimaryColor", + table: "AppUserCollection"); + + migrationBuilder.DropColumn( + name: "SecondaryColor", + table: "AppUserCollection"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index c59b774d63..8dd522104e 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -15,7 +15,7 @@ partial class DataContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.4"); + modelBuilder.HasAnnotation("ProductVersion", "8.0.7"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -230,9 +230,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("NormalizedTitle") .HasColumnType("TEXT"); + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + b.Property("Promoted") .HasColumnType("INTEGER"); + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + b.Property("Source") .HasColumnType("INTEGER"); @@ -775,12 +781,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Pages") .HasColumnType("INTEGER"); + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + b.Property("Range") .HasColumnType("TEXT"); b.Property("ReleaseDate") .HasColumnType("TEXT"); + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + b.Property("SeriesGroup") .HasColumnType("TEXT"); @@ -999,6 +1011,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Name") .HasColumnType("TEXT"); + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + b.Property("Type") .HasColumnType("INTEGER"); @@ -1504,9 +1522,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + b.Property("Promoted") .HasColumnType("INTEGER"); + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + b.Property("StartingMonth") .HasColumnType("INTEGER"); @@ -1794,6 +1818,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Pages") .HasColumnType("INTEGER"); + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + b.Property("SortName") .HasColumnType("TEXT"); @@ -1989,6 +2019,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Pages") .HasColumnType("INTEGER"); + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + b.Property("SeriesId") .HasColumnType("INTEGER"); diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index a1d2d754ed..965bf343ed 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -83,7 +83,7 @@ public async Task Count() return await _context.ReadingList .Where(c => c.Id == readingListId) .Select(c => c.CoverImage) - .SingleOrDefaultAsync(); + .FirstOrDefaultAsync(); } public async Task> GetAllCoverImagesAsync() diff --git a/API/Entities/AppUserCollection.cs b/API/Entities/AppUserCollection.cs index 7ff60e0e80..21d707c2fe 100644 --- a/API/Entities/AppUserCollection.cs +++ b/API/Entities/AppUserCollection.cs @@ -10,7 +10,7 @@ namespace API.Entities; /// /// Represents a Collection of Series for a given User /// -public class AppUserCollection : IEntityDate +public class AppUserCollection : IEntityDate, IHasCoverImage { public int Id { get; set; } public required string Title { get; set; } @@ -23,11 +23,9 @@ public class AppUserCollection : IEntityDate /// Reading lists that are promoted are only done by admins /// public bool Promoted { get; set; } - /// - /// Path to the (managed) image file - /// - /// The file is managed internally to Kavita's APPDIR public string? CoverImage { get; set; } + public string PrimaryColor { get; set; } + public string SecondaryColor { get; set; } public bool CoverImageLocked { get; set; } /// /// The highest age rating from all Series within the collection diff --git a/API/Entities/Chapter.cs b/API/Entities/Chapter.cs index f0d557327e..0c779d52e2 100644 --- a/API/Entities/Chapter.cs +++ b/API/Entities/Chapter.cs @@ -9,7 +9,7 @@ namespace API.Entities; -public class Chapter : IEntityDate, IHasReadTimeEstimate +public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage { public int Id { get; set; } /// @@ -46,11 +46,9 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate public DateTime CreatedUtc { get; set; } public DateTime LastModifiedUtc { get; set; } - /// - /// Relative path to the (managed) image file representing the cover image - /// - /// The file is managed internally to Kavita's APPDIR public string? CoverImage { get; set; } + public string PrimaryColor { get; set; } + public string SecondaryColor { get; set; } public bool CoverImageLocked { get; set; } /// /// Total number of pages in all MangaFiles diff --git a/API/Entities/Interfaces/IHasCoverImage.cs b/API/Entities/Interfaces/IHasCoverImage.cs new file mode 100644 index 0000000000..4df55587c9 --- /dev/null +++ b/API/Entities/Interfaces/IHasCoverImage.cs @@ -0,0 +1,19 @@ +namespace API.Entities.Interfaces; + +public interface IHasCoverImage +{ + /// + /// Absolute path to the (managed) image file + /// + /// The file is managed internally to Kavita's APPDIR + public string? CoverImage { get; set; } + + /// + /// Primary color derived from the Cover Image + /// + public string? PrimaryColor { get; set; } + /// + /// Secondary color derived from the Cover Image + /// + public string? SecondaryColor { get; set; } +} diff --git a/API/Entities/Library.cs b/API/Entities/Library.cs index aa53b6651a..9fbc4a5920 100644 --- a/API/Entities/Library.cs +++ b/API/Entities/Library.cs @@ -5,11 +5,13 @@ namespace API.Entities; -public class Library : IEntityDate +public class Library : IEntityDate, IHasCoverImage { public int Id { get; set; } public required string Name { get; set; } public string? CoverImage { get; set; } + public string PrimaryColor { get; set; } + public string SecondaryColor { get; set; } public LibraryType Type { get; set; } /// /// If Folder Watching is enabled for this library diff --git a/API/Entities/ReadingList.cs b/API/Entities/ReadingList.cs index 857d5bd426..359e576e6c 100644 --- a/API/Entities/ReadingList.cs +++ b/API/Entities/ReadingList.cs @@ -10,7 +10,7 @@ namespace API.Entities; /// /// This is a collection of which represent individual chapters and an order. /// -public class ReadingList : IEntityDate +public class ReadingList : IEntityDate, IHasCoverImage { public int Id { get; init; } public required string Title { get; set; } @@ -23,11 +23,9 @@ public class ReadingList : IEntityDate /// Reading lists that are promoted are only done by admins /// public bool Promoted { get; set; } - /// - /// Absolute path to the (managed) image file - /// - /// The file is managed internally to Kavita's APPDIR public string? CoverImage { get; set; } + public string? PrimaryColor { get; set; } + public string? SecondaryColor { get; set; } public bool CoverImageLocked { get; set; } /// diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index d42d475071..65a5330bc0 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -3,11 +3,10 @@ using API.Entities.Enums; using API.Entities.Interfaces; using API.Entities.Metadata; -using API.Extensions; namespace API.Entities; -public class Series : IEntityDate, IHasReadTimeEstimate +public class Series : IEntityDate, IHasReadTimeEstimate, IHasCoverImage { public int Id { get; set; } /// @@ -82,6 +81,9 @@ public class Series : IEntityDate, IHasReadTimeEstimate /// public MangaFormat Format { get; set; } = MangaFormat.Unknown; + public string PrimaryColor { get; set; } = string.Empty; + public string SecondaryColor { get; set; } = string.Empty; + public bool SortNameLocked { get; set; } public bool LocalizedNameLocked { get; set; } diff --git a/API/Entities/Volume.cs b/API/Entities/Volume.cs index dc1db447c2..bf7312d3dd 100644 --- a/API/Entities/Volume.cs +++ b/API/Entities/Volume.cs @@ -6,7 +6,7 @@ namespace API.Entities; -public class Volume : IEntityDate, IHasReadTimeEstimate +public class Volume : IEntityDate, IHasReadTimeEstimate, IHasCoverImage { public int Id { get; set; } /// @@ -38,11 +38,10 @@ public class Volume : IEntityDate, IHasReadTimeEstimate public DateTime CreatedUtc { get; set; } public DateTime LastModifiedUtc { get; set; } - /// - /// Absolute path to the (managed) image file - /// - /// The file is managed internally to Kavita's APPDIR public string? CoverImage { get; set; } + public string PrimaryColor { get; set; } + public string SecondaryColor { get; set; } + /// /// Total pages of all chapters in this volume /// diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 24be99d92b..e4ed920473 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -1223,7 +1223,7 @@ public string GetCoverImage(string fileFilePath, string fileName, string outputD { // Try to get the cover image from OPF file, if not set, try to parse it from all the files, then result to the first one. var coverImageContent = epubBook.Content.Cover - ?? epubBook.Content.Images.Local.FirstOrDefault(file => Parser.IsCoverImage(file.FilePath)) // FileName -> FilePath + ?? epubBook.Content.Images.Local.FirstOrDefault(file => Parser.IsCoverImage(file.FilePath)) ?? epubBook.Content.Images.Local.FirstOrDefault(); if (coverImageContent == null) return string.Empty; diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 7b0b26c165..ad6829b7dd 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -1,10 +1,14 @@ using System; using System.Collections.Generic; +using System.Drawing; using System.IO; using System.Linq; +using System.Numerics; using System.Threading.Tasks; using API.Constants; +using API.DTOs; using API.Entities.Enums; +using API.Entities.Interfaces; using API.Extensions; using EasyCaching.Core; using Flurl; @@ -13,6 +17,9 @@ using Kavita.Common; using Microsoft.Extensions.Logging; using NetVips; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Processing.Processors.Quantization; using Image = NetVips.Image; namespace API.Services; @@ -60,6 +67,7 @@ public interface IImageService Task ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat); Task IsImage(string filePath); Task DownloadFaviconAsync(string url, EncodeFormat encodeFormat); + void UpdateColorScape(IHasCoverImage entity); } public class ImageService : IImageService @@ -73,6 +81,9 @@ public class ImageService : IImageService public const string CollectionTagCoverImageRegex = @"tag\d+"; public const string ReadingListCoverImageRegex = @"readinglist\d+"; + private const double WhiteThreshold = 0.90; // Colors with lightness above this are considered too close to white + private const double BlackThreshold = 0.25; // Colors with lightness below this are considered too close to black + /// /// Width of the Thumbnail generation @@ -415,13 +426,266 @@ public async Task DownloadFaviconAsync(string url, EncodeFormat encodeFo _logger.LogDebug("Favicon.png for {Domain} downloaded and saved successfully", domain); return filename; - }catch (Exception ex) + } catch (Exception ex) { _logger.LogError(ex, "Error downloading favicon.png for {Domain}", domain); throw; } } + private static (Vector3?, Vector3?) GetPrimarySecondaryColors(string imagePath) + { + using var image = Image.NewFromFile(imagePath); + // Resize the image to speed up processing + var resizedImage = image.Resize(0.1); + + + // Convert image to RGB array + var pixels = resizedImage.WriteToMemory().ToArray(); + + // Convert to list of Vector3 (RGB) + var rgbPixels = new List(); + for (var i = 0; i < pixels.Length - 2; i += 3) + { + rgbPixels.Add(new Vector3(pixels[i], pixels[i + 1], pixels[i + 2])); + } + + // Perform k-means clustering + var clusters = KMeansClustering(rgbPixels, 4); + + var sorted = SortByVibrancy(clusters); + + if (sorted.Count >= 2) + { + return (sorted[0], sorted[1]); + } + if (sorted.Count == 1) + { + return (sorted[0], null); + } + + return (null, null); + } + + private static (Vector3?, Vector3?) GetPrimaryColorSharp(string imagePath) + { + using var image = SixLabors.ImageSharp.Image.Load(imagePath); + + image.Mutate( + x => x + // Scale the image down preserving the aspect ratio. This will speed up quantization. + // We use nearest neighbor as it will be the fastest approach. + .Resize(new ResizeOptions() { Sampler = KnownResamplers.NearestNeighbor, Size = new SixLabors.ImageSharp.Size(100, 0) }) + + // Reduce the color palette to 1 color without dithering. + .Quantize(new OctreeQuantizer(new QuantizerOptions { MaxColors = 4 }))); + + Rgb24 dominantColor = image[0, 0]; + + // This will give you a dominant color in HEX format i.e #5E35B1FF + return (new Vector3(dominantColor.R, dominantColor.G, dominantColor.B), new Vector3(dominantColor.R, dominantColor.G, dominantColor.B)); + } + + private static Image PreProcessImage(Image image) + { + // Create a mask for white and black pixels + var whiteMask = image.Colourspace(Enums.Interpretation.Lab)[0] > (WhiteThreshold * 100); + var blackMask = image.Colourspace(Enums.Interpretation.Lab)[0] < (BlackThreshold * 100); + + // Create a replacement color (e.g., medium gray) + var replacementColor = new[] { 128.0, 128.0, 128.0 }; + + // Apply the masks to replace white and black pixels + var processedImage = image.Copy(); + processedImage = processedImage.Ifthenelse(whiteMask, replacementColor); + processedImage = processedImage.Ifthenelse(blackMask, replacementColor); + + return processedImage; + } + + private static Dictionary GenerateColorHistogram(Image image) + { + var pixels = image.WriteToMemory().ToArray(); + var histogram = new Dictionary(); + + for (var i = 0; i < pixels.Length; i += 3) + { + var color = new Vector3(pixels[i], pixels[i + 1], pixels[i + 2]); + if (!histogram.TryAdd(color, 1)) + { + histogram[color]++; + } + } + + return histogram; + } + + private static bool IsColorCloseToWhiteOrBlack(Vector3 color) + { + var (_, _, lightness) = RgbToHsl(color); + return lightness is > WhiteThreshold or < BlackThreshold; + } + + private static List KMeansClustering(List points, int k, int maxIterations = 100) + { + var random = new Random(); + var centroids = points.OrderBy(x => random.Next()).Take(k).ToList(); + + for (var i = 0; i < maxIterations; i++) + { + var clusters = new List[k]; + for (var j = 0; j < k; j++) + { + clusters[j] = []; + } + + foreach (var point in points) + { + var nearestCentroidIndex = centroids + .Select((centroid, index) => new { Index = index, Distance = Vector3.DistanceSquared(centroid, point) }) + .OrderBy(x => x.Distance) + .First().Index; + clusters[nearestCentroidIndex].Add(point); + } + + var newCentroids = clusters.Select(cluster => + cluster.Count != 0 ? new Vector3( + cluster.Average(p => p.X), + cluster.Average(p => p.Y), + cluster.Average(p => p.Z) + ) : Vector3.Zero + ).ToList(); + + if (centroids.SequenceEqual(newCentroids)) + break; + + centroids = newCentroids; + } + + 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) + { + return colors.OrderByDescending(c => + { + float max = Math.Max(c.X, Math.Max(c.Y, c.Z)); + float min = Math.Min(c.X, Math.Min(c.Y, c.Z)); + return (max - min) / max; + }).ToList(); + } + + private static string RgbToHex(Vector3 color) + { + return $"#{(int)color.X:X2}{(int)color.Y:X2}{(int)color.Z:X2}"; + } + + private static Vector3 GetComplementaryColor(Vector3 color) + { + // Convert RGB to HSL + var (h, s, l) = RgbToHsl(color); + + // Rotate hue by 180 degrees + h = (h + 180) % 360; + + // Convert back to RGB + return HslToRgb(h, s, l); + } + + private static (double H, double S, double L) RgbToHsl(Vector3 rgb) + { + double r = rgb.X / 255; + double g = rgb.Y / 255; + double b = rgb.Z / 255; + + var max = Math.Max(r, Math.Max(g, b)); + var min = Math.Min(r, Math.Min(g, b)); + var diff = max - min; + + double h = 0; + double s = 0; + var l = (max + min) / 2; + + if (Math.Abs(diff) > 0.00001) + { + s = l > 0.5 ? diff / (2 - max - min) : diff / (max + min); + + if (max == r) + h = (g - b) / diff + (g < b ? 6 : 0); + else if (max == g) + h = (b - r) / diff + 2; + else if (max == b) + h = (r - g) / diff + 4; + + h *= 60; + } + + return (h, s, l); + } + + private static Vector3 HslToRgb(double h, double s, double l) + { + double r, g, b; + + if (Math.Abs(s) < 0.00001) + { + r = g = b = l; + } + else + { + var q = l < 0.5 ? l * (1 + s) : l + s - l * s; + var p = 2 * l - q; + r = HueToRgb(p, q, h + 120); + g = HueToRgb(p, q, h); + b = HueToRgb(p, q, h - 120); + } + + return new Vector3((float)(r * 255), (float)(g * 255), (float)(b * 255)); + } + + private static double HueToRgb(double p, double q, double t) + { + if (t < 0) t += 360; + if (t > 360) t -= 360; + return t switch + { + < 60 => p + (q - p) * t / 60, + < 180 => q, + < 240 => p + (q - p) * (240 - t) / 60, + _ => p + }; + } + + /// + /// Generates the Primary and Secondary colors from a file + /// + /// This may use a second most common color or a complementary color. It's up to implemenation to choose what's best + /// + /// + public static ColorScape CalculateColorScape(string sourceFile) + { + if (!File.Exists(sourceFile)) return new ColorScape() {Primary = null, Secondary = null}; + + var colors = GetPrimarySecondaryColors(sourceFile); + + return new ColorScape() + { + Primary = colors.Item1 == null ? null : RgbToHex(colors.Item1.Value), + Secondary = colors.Item2 == null ? null : RgbToHex(colors.Item2.Value) + }; + } + private static string FallbackToKavitaReaderFavicon(string baseUrl) { var correctSizeLink = string.Empty; @@ -582,4 +846,39 @@ public static void CreateMergedImage(IList coverImages, CoverImageSize s image.WriteToFile(dest); } + + public void UpdateColorScape(IHasCoverImage entity) + { + var colors = CalculateColorScape( + _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, entity.CoverImage)); + entity.PrimaryColor = colors.Primary; + entity.SecondaryColor = colors.Secondary; + } + + public static Color HexToRgb(string? hex) + { + if (string.IsNullOrEmpty(hex)) throw new ArgumentException("Hex cannot be null"); + + // Remove the leading '#' if present + hex = hex.TrimStart('#'); + + // Ensure the hex string is valid + if (hex.Length != 6 && hex.Length != 3) + { + throw new ArgumentException("Hex string should be 6 or 3 characters long."); + } + + if (hex.Length == 3) + { + // Expand shorthand notation to full form (e.g., "abc" -> "aabbcc") + hex = string.Concat(hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]); + } + + // Parse the hex string into RGB components + var r = Convert.ToInt32(hex.Substring(0, 2), 16); + var g = Convert.ToInt32(hex.Substring(2, 2), 16); + var b = Convert.ToInt32(hex.Substring(4, 2), 16); + + return Color.FromArgb(r, g, b); + } } diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index dd2108d980..8e8ba4cf07 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -1,12 +1,14 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Linq; using System.Threading.Tasks; using API.Comparators; using API.Data; using API.Entities; using API.Entities.Enums; +using API.Entities.Interfaces; using API.Extensions; using API.Helpers; using API.SignalR; @@ -38,6 +40,9 @@ public interface IMetadataService Task RemoveAbandonedMetadataKeys(); } +/// +/// Handles everything around Cover/ColorScape management +/// public class MetadataService : IMetadataService { public const string Name = "MetadataService"; @@ -47,10 +52,13 @@ public class MetadataService : IMetadataService private readonly ICacheHelper _cacheHelper; private readonly IReadingItemService _readingItemService; private readonly IDirectoryService _directoryService; + private readonly IImageService _imageService; private readonly IList _updateEvents = new List(); + public MetadataService(IUnitOfWork unitOfWork, ILogger logger, IEventHub eventHub, ICacheHelper cacheHelper, - IReadingItemService readingItemService, IDirectoryService directoryService) + IReadingItemService readingItemService, IDirectoryService directoryService, + IImageService imageService) { _unitOfWork = unitOfWork; _logger = logger; @@ -58,6 +66,7 @@ public MetadataService(IUnitOfWork unitOfWork, ILogger logger, _cacheHelper = cacheHelper; _readingItemService = readingItemService; _directoryService = directoryService; + _imageService = imageService; } /// @@ -71,16 +80,28 @@ private Task UpdateChapterCoverImage(Chapter chapter, bool forceUpdate, En var firstFile = chapter.Files.MinBy(x => x.Chapter); if (firstFile == null) return Task.FromResult(false); - if (!_cacheHelper.ShouldUpdateCoverImage(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage), + if (!_cacheHelper.ShouldUpdateCoverImage( + _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage), firstFile, chapter.Created, forceUpdate, chapter.CoverImageLocked)) - return Task.FromResult(false); + { + if (NeedsColorSpace(chapter)) + { + _imageService.UpdateColorScape(chapter); + _unitOfWork.ChapterRepository.Update(chapter); + _updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter)); + } + return Task.FromResult(false); + } _logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile.FilePath); chapter.CoverImage = _readingItemService.GetCoverImage(firstFile.FilePath, ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format, encodeFormat, coverImageSize); + + _imageService.UpdateColorScape(chapter); + _unitOfWork.ChapterRepository.Update(chapter); _updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter)); @@ -95,6 +116,15 @@ private void UpdateChapterLastModified(Chapter chapter, bool forceUpdate) firstFile.UpdateLastModified(); } + private static bool NeedsColorSpace(IHasCoverImage? entity) + { + if (entity == null) return false; + return !string.IsNullOrEmpty(entity.CoverImage) && + (string.IsNullOrEmpty(entity.PrimaryColor) || string.IsNullOrEmpty(entity.SecondaryColor)); + } + + + /// /// Updates the cover image for a Volume /// @@ -105,8 +135,16 @@ private Task UpdateVolumeCoverImage(Volume? volume, bool forceUpdate) // We need to check if Volume coverImage matches first chapters if forceUpdate is false if (volume == null || !_cacheHelper.ShouldUpdateCoverImage( _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, volume.CoverImage), - null, volume.Created, forceUpdate)) return Task.FromResult(false); - + null, volume.Created, forceUpdate)) + { + if (NeedsColorSpace(volume)) + { + _imageService.UpdateColorScape(volume); + _unitOfWork.VolumeRepository.Update(volume); + _updateEvents.Add(MessageFactory.CoverUpdateEvent(volume.Id, MessageFactoryEntityTypes.Volume)); + } + return Task.FromResult(false); + } // For cover selection, chapters need to try for issue 1 first, then fallback to first sort order volume.Chapters ??= new List(); @@ -118,7 +156,10 @@ private Task UpdateVolumeCoverImage(Volume? volume, bool forceUpdate) if (firstChapter == null) return Task.FromResult(false); } + volume.CoverImage = firstChapter.CoverImage; + _imageService.UpdateColorScape(volume); + _updateEvents.Add(MessageFactory.CoverUpdateEvent(volume.Id, MessageFactoryEntityTypes.Volume)); return Task.FromResult(true); @@ -133,13 +174,26 @@ private Task UpdateSeriesCoverImage(Series? series, bool forceUpdate) { if (series == null) return Task.CompletedTask; - if (!_cacheHelper.ShouldUpdateCoverImage(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, series.CoverImage), + if (!_cacheHelper.ShouldUpdateCoverImage( + _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, series.CoverImage), null, series.Created, forceUpdate, series.CoverImageLocked)) + { + // Check if we don't have a primary/seconary color + if (NeedsColorSpace(series)) + { + _imageService.UpdateColorScape(series); + _updateEvents.Add(MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series)); + } + + return Task.CompletedTask; + } series.Volumes ??= []; series.CoverImage = series.GetCoverImage(); + _imageService.UpdateColorScape(series); + _updateEvents.Add(MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series)); return Task.CompletedTask; } diff --git a/API/Services/ReadingListService.cs b/API/Services/ReadingListService.cs index 9d9c7cf6be..00d968ec1b 100644 --- a/API/Services/ReadingListService.cs +++ b/API/Services/ReadingListService.cs @@ -48,6 +48,7 @@ Task AddChaptersToReadingList(int seriesId, IList chapterIds, Task CreateReadingListsFromSeries(Series series, Library library); Task CreateReadingListsFromSeries(int libraryId, int seriesId); + Task GenerateReadingListCoverImage(int readingListId); } /// @@ -59,15 +60,20 @@ public class ReadingListService : IReadingListService private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IEventHub _eventHub; - private readonly ChapterSortComparerDefaultFirst _chapterSortComparerForInChapterSorting = ChapterSortComparerDefaultFirst.Default; + private readonly IImageService _imageService; + private readonly IDirectoryService _directoryService; + private static readonly Regex JustNumbers = new Regex(@"^\d+$", RegexOptions.Compiled | RegexOptions.IgnoreCase, Parser.RegexTimeout); - public ReadingListService(IUnitOfWork unitOfWork, ILogger logger, IEventHub eventHub) + public ReadingListService(IUnitOfWork unitOfWork, ILogger logger, + IEventHub eventHub, IImageService imageService, IDirectoryService directoryService) { _unitOfWork = unitOfWork; _logger = logger; _eventHub = eventHub; + _imageService = imageService; + _directoryService = directoryService; } public static string FormatTitle(ReadingListItemDto item) @@ -488,8 +494,12 @@ public async Task CreateReadingListsFromSeries(Series series, Library library) if (!_unitOfWork.HasChanges()) continue; + + _imageService.UpdateColorScape(readingList); await CalculateReadingListAgeRating(readingList); + await _unitOfWork.CommitAsync(); // TODO: See if we can avoid this extra commit by reworking bottom logic + await CalculateStartAndEndDates(await _unitOfWork.ReadingListRepository.GetReadingListByTitleAsync(arcPair.Item1, user.Id, ReadingListIncludes.Items | ReadingListIncludes.ItemChapter)); await _unitOfWork.CommitAsync(); @@ -632,6 +642,7 @@ public async Task CreateReadingListFromCbl(int userId, CblR var allSeriesLocalized = userSeries.ToDictionary(s => s.NormalizedLocalizedName); var readingListNameNormalized = Parser.Normalize(cblReading.Name); + // Get all the user's reading lists var allReadingLists = (user.ReadingLists).ToDictionary(s => s.NormalizedTitle); if (!allReadingLists.TryGetValue(readingListNameNormalized, out var readingList)) @@ -736,7 +747,10 @@ public async Task CreateReadingListFromCbl(int userId, CblR } // If there are no items, don't create a blank list - if (!_unitOfWork.HasChanges() || !readingList.Items.Any()) return importSummary; + if (!_unitOfWork.HasChanges() || readingList.Items.Count == 0) return importSummary; + + + _imageService.UpdateColorScape(readingList); await _unitOfWork.CommitAsync(); @@ -787,4 +801,33 @@ public static CblReadingList LoadCblFromPath(string path) file.Close(); return cblReadingList; } + + public async Task GenerateReadingListCoverImage(int readingListId) + { + // TODO: Currently reading lists are dynamically generated at runtime. This needs to be overhauled to be generated and stored within + // the Reading List (and just expire every so often) so we can utilize ColorScapes. + // Check if a cover already exists for the reading list + // var potentialExistingCoverPath = _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, + // ImageService.GetReadingListFormat(readingListId)); + // if (_directoryService.FileSystem.File.Exists(potentialExistingCoverPath)) + // { + // // Check if we need to update CoverScape + // + // } + + var covers = await _unitOfWork.ReadingListRepository.GetRandomCoverImagesAsync(readingListId); + var destFile = _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, + ImageService.GetReadingListFormat(readingListId)); + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + destFile += settings.EncodeMediaAs.GetExtension(); + + if (_directoryService.FileSystem.File.Exists(destFile)) return destFile; + ImageService.CreateMergedImage( + covers.Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList(), + settings.CoverImageSize, + destFile); + // TODO: Refactor this so that reading lists have a dedicated cover image so we can calculate primary/secondary colors + + return !_directoryService.FileSystem.File.Exists(destFile) ? string.Empty : destFile; + } } diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index de4c4e6070..f1a6eb383a 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -97,7 +97,6 @@ public async Task> GetAllReleases() // isNightly can be true when we compare something like v0.8.1 vs v0.8.1.0 if (IsVersionEqualToBuildVersion(updateVersion)) { - //latestRelease.UpdateVersion = BuildInfo.Version.ToString(); isNightly = false; } diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 36c87a27e8..729ba2e7e3 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -14,10 +14,10 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + \ No newline at end of file diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 3224cf3035..2b6eedf493 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -504,7 +504,6 @@ "version": "17.3.4", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.3.4.tgz", "integrity": "sha512-TVWjpZSI/GIXTYsmVgEKYjBckcW8Aj62DcxLNehRFR+c7UB95OY3ZFjU8U4jL0XvWPgTkkVWQVq+P6N4KCBsyw==", - "dev": true, "dependencies": { "@babel/core": "7.23.9", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -532,7 +531,6 @@ "version": "7.23.9", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz", "integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==", - "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -561,14 +559,12 @@ "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "bin": { "semver": "bin/semver.js" } @@ -749,7 +745,6 @@ "version": "7.24.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", "integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==", - "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -778,14 +773,12 @@ "node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "bin": { "semver": "bin/semver.js" } @@ -5629,7 +5622,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -5642,7 +5634,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -5914,7 +5905,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, "engines": { "node": ">=8" }, @@ -6226,7 +6216,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -6518,8 +6507,7 @@ "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/cookie": { "version": "0.6.0", @@ -7421,7 +7409,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -7431,7 +7418,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -8540,7 +8526,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -9222,7 +9207,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -11063,7 +11047,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -12453,7 +12436,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -12465,7 +12447,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -12476,8 +12457,7 @@ "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, "node_modules/regenerate": { "version": "1.4.2", @@ -12945,7 +12925,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "devOptional": true }, "node_modules/sass": { "version": "1.71.1", @@ -13064,7 +13044,6 @@ "version": "7.6.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -13079,7 +13058,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -13090,8 +13068,7 @@ "node_modules/semver/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/send": { "version": "0.18.0", @@ -14222,7 +14199,6 @@ "version": "5.4.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", - "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/UI/Web/src/app/_models/chapter.ts b/UI/Web/src/app/_models/chapter.ts index f4e86d4d39..3e1c8324cd 100644 --- a/UI/Web/src/app/_models/chapter.ts +++ b/UI/Web/src/app/_models/chapter.ts @@ -79,4 +79,7 @@ export interface Chapter { translators: Array; teams: Array; locations: Array; + + primaryColor?: string; + secondaryColor?: string; } diff --git a/UI/Web/src/app/_models/reading-list.ts b/UI/Web/src/app/_models/reading-list.ts index b11037a51c..2e958e3988 100644 --- a/UI/Web/src/app/_models/reading-list.ts +++ b/UI/Web/src/app/_models/reading-list.ts @@ -31,6 +31,8 @@ export interface ReadingList { * If this is empty or null, the cover image isn't set. Do not use this externally. */ coverImage: string; + primaryColor?: string; + secondaryColor?: string; startingYear: number; startingMonth: number; endingYear: number; diff --git a/UI/Web/src/app/_models/series.ts b/UI/Web/src/app/_models/series.ts index c994a35274..0ffc5563fb 100644 --- a/UI/Web/src/app/_models/series.ts +++ b/UI/Web/src/app/_models/series.ts @@ -64,4 +64,7 @@ export interface Series { * This is currently only used on Series detail page for recommendations */ summary?: string; + coverImage?: string; + primaryColor: string; + secondaryColor: string; } diff --git a/UI/Web/src/app/_models/theme/colorscape.ts b/UI/Web/src/app/_models/theme/colorscape.ts new file mode 100644 index 0000000000..1fbd436fed --- /dev/null +++ b/UI/Web/src/app/_models/theme/colorscape.ts @@ -0,0 +1,4 @@ +export interface ColorScape { + primary?: string; + secondary?: string; +} diff --git a/UI/Web/src/app/_models/volume.ts b/UI/Web/src/app/_models/volume.ts index e944438a31..ffe333b7b1 100644 --- a/UI/Web/src/app/_models/volume.ts +++ b/UI/Web/src/app/_models/volume.ts @@ -18,4 +18,8 @@ export interface Volume { minHoursToRead: number; maxHoursToRead: number; avgHoursToRead: number; + + coverImage?: string; + primaryColor: string; + secondaryColor: string; } diff --git a/UI/Web/src/app/_models/wiki.ts b/UI/Web/src/app/_models/wiki.ts index de5b6d4f8a..381a386380 100644 --- a/UI/Web/src/app/_models/wiki.ts +++ b/UI/Web/src/app/_models/wiki.ts @@ -18,5 +18,7 @@ export enum WikiLink { ScannerExclude = 'https://wiki.kavitareader.com/guides/admin-settings/libraries#exclude-patterns', Library = 'https://wiki.kavitareader.com/guides/admin-settings/libraries', UpdateNative = 'https://wiki.kavitareader.com/guides/updating/updating-native', - UpdateDocker = 'https://wiki.kavitareader.com/guides/updating/updating-docker' + UpdateDocker = 'https://wiki.kavitareader.com/guides/updating/updating-docker', + OpdsClients = 'https://wiki.kavitareader.com/guides/opds#opds-capable-clients', + Guides = 'https://wiki.kavitareader.com/guides' } diff --git a/UI/Web/src/app/_pipes/book-page-layout-mode.pipe.ts b/UI/Web/src/app/_pipes/book-page-layout-mode.pipe.ts new file mode 100644 index 0000000000..beef289b4d --- /dev/null +++ b/UI/Web/src/app/_pipes/book-page-layout-mode.pipe.ts @@ -0,0 +1,19 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {translate} from "@ngneat/transloco"; +import {BookPageLayoutMode} from "../_models/readers/book-page-layout-mode"; + +@Pipe({ + name: 'bookPageLayoutMode', + standalone: true +}) +export class BookPageLayoutModePipe implements PipeTransform { + + transform(value: BookPageLayoutMode): string { + switch (value) { + case BookPageLayoutMode.Column1: return translate('preferences.1-column'); + case BookPageLayoutMode.Column2: return translate('preferences.2-column'); + case BookPageLayoutMode.Default: return translate('preferences.scroll'); + } + } + +} diff --git a/UI/Web/src/app/_pipes/cover-image-size.pipe.ts b/UI/Web/src/app/_pipes/cover-image-size.pipe.ts new file mode 100644 index 0000000000..d0fca1a53b --- /dev/null +++ b/UI/Web/src/app/_pipes/cover-image-size.pipe.ts @@ -0,0 +1,25 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {CoverImageSize} from "../admin/_models/cover-image-size"; +import {translate} from "@ngneat/transloco"; + +@Pipe({ + name: 'coverImageSize', + standalone: true +}) +export class CoverImageSizePipe implements PipeTransform { + + transform(value: CoverImageSize): string { + switch (value) { + case CoverImageSize.Default: + return translate('cover-image-size.default'); + case CoverImageSize.Medium: + return translate('cover-image-size.medium'); + case CoverImageSize.Large: + return translate('cover-image-size.large'); + case CoverImageSize.XLarge: + return translate('cover-image-size.xlarge'); + + } + } + +} diff --git a/UI/Web/src/app/_pipes/encode-format.pipe.ts b/UI/Web/src/app/_pipes/encode-format.pipe.ts new file mode 100644 index 0000000000..9485f80f84 --- /dev/null +++ b/UI/Web/src/app/_pipes/encode-format.pipe.ts @@ -0,0 +1,21 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {EncodeFormat} from "../admin/_models/encode-format"; + +@Pipe({ + name: 'encodeFormat', + standalone: true +}) +export class EncodeFormatPipe implements PipeTransform { + + transform(value: EncodeFormat): string { + switch (value) { + case EncodeFormat.PNG: + return 'PNG'; + case EncodeFormat.WebP: + return 'WebP'; + case EncodeFormat.AVIF: + return 'AVIF'; + } + } + +} diff --git a/UI/Web/src/app/_pipes/layout-mode.pipe.ts b/UI/Web/src/app/_pipes/layout-mode.pipe.ts new file mode 100644 index 0000000000..9987347fe2 --- /dev/null +++ b/UI/Web/src/app/_pipes/layout-mode.pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {translate} from "@ngneat/transloco"; +import {LayoutMode} from "../manga-reader/_models/layout-mode"; + +@Pipe({ + name: 'layoutMode', + standalone: true +}) +export class LayoutModePipe implements PipeTransform { + + transform(value: LayoutMode): string { + switch (value) { + case LayoutMode.Single: return translate('preferences.single'); + case LayoutMode.Double: return translate('preferences.double'); + case LayoutMode.DoubleReversed: return translate('preferences.double-manga'); + case LayoutMode.DoubleNoCover: return translate('preferences.double-no-cover'); + } + } + +} diff --git a/UI/Web/src/app/_pipes/page-layout-mode.pipe.ts b/UI/Web/src/app/_pipes/page-layout-mode.pipe.ts new file mode 100644 index 0000000000..a0c3a9d669 --- /dev/null +++ b/UI/Web/src/app/_pipes/page-layout-mode.pipe.ts @@ -0,0 +1,18 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {PageLayoutMode} from "../_models/page-layout-mode"; +import {translate} from "@ngneat/transloco"; + +@Pipe({ + name: 'pageLayoutMode', + standalone: true +}) +export class PageLayoutModePipe implements PipeTransform { + + transform(value: PageLayoutMode): string { + switch (value) { + case PageLayoutMode.Cards: return translate('preferences.cards'); + case PageLayoutMode.List: return translate('preferences.list'); + } + } + +} diff --git a/UI/Web/src/app/_pipes/page-split-option.pipe.ts b/UI/Web/src/app/_pipes/page-split-option.pipe.ts new file mode 100644 index 0000000000..a5436255db --- /dev/null +++ b/UI/Web/src/app/_pipes/page-split-option.pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {translate} from "@ngneat/transloco"; +import {PageSplitOption} from "../_models/preferences/page-split-option"; + +@Pipe({ + name: 'pageSplitOption', + standalone: true +}) +export class PageSplitOptionPipe implements PipeTransform { + + transform(value: PageSplitOption): string { + switch (value) { + case PageSplitOption.FitSplit: return translate('preferences.fit-to-screen'); + case PageSplitOption.NoSplit: return translate('preferences.no-split'); + case PageSplitOption.SplitLeftToRight: return translate('preferences.split-left-to-right'); + case PageSplitOption.SplitRightToLeft: return translate('preferences.split-right-to-left'); + } + } + +} diff --git a/UI/Web/src/app/_pipes/pdf-scroll-mode.pipe.ts b/UI/Web/src/app/_pipes/pdf-scroll-mode.pipe.ts new file mode 100644 index 0000000000..281cd977a2 --- /dev/null +++ b/UI/Web/src/app/_pipes/pdf-scroll-mode.pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {translate} from "@ngneat/transloco"; +import {PdfScrollMode} from "../_models/preferences/pdf-scroll-mode"; + +@Pipe({ + name: 'pdfScrollMode', + standalone: true +}) +export class PdfScrollModePipe implements PipeTransform { + + transform(value: PdfScrollMode): string { + switch (value) { + case PdfScrollMode.Wrapped: return translate('preferences.pdf-multiple'); + case PdfScrollMode.Page: return translate('preferences.pdf-page'); + case PdfScrollMode.Horizontal: return translate('preferences.pdf-horizontal'); + case PdfScrollMode.Vertical: return translate('preferences.pdf-vertical'); + } + } + +} diff --git a/UI/Web/src/app/_pipes/pdf-spread-mode.pipe.ts b/UI/Web/src/app/_pipes/pdf-spread-mode.pipe.ts new file mode 100644 index 0000000000..04584d256a --- /dev/null +++ b/UI/Web/src/app/_pipes/pdf-spread-mode.pipe.ts @@ -0,0 +1,19 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {PdfSpreadMode} from "../_models/preferences/pdf-spread-mode"; +import {translate} from "@ngneat/transloco"; + +@Pipe({ + name: 'pdfSpreadMode', + standalone: true +}) +export class PdfSpreadModePipe implements PipeTransform { + + transform(value: PdfSpreadMode): string { + switch (value) { + case PdfSpreadMode.None: return translate('preferences.pdf-none'); + case PdfSpreadMode.Odd: return translate('preferences.pdf-odd'); + case PdfSpreadMode.Even: return translate('preferences.pdf-even'); + } + } + +} diff --git a/UI/Web/src/app/_pipes/pdf-theme.pipe.ts b/UI/Web/src/app/_pipes/pdf-theme.pipe.ts new file mode 100644 index 0000000000..d5aa0d7ccd --- /dev/null +++ b/UI/Web/src/app/_pipes/pdf-theme.pipe.ts @@ -0,0 +1,18 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {PdfTheme} from "../_models/preferences/pdf-theme"; +import {translate} from "@ngneat/transloco"; + +@Pipe({ + name: 'pdfTheme', + standalone: true +}) +export class PdfThemePipe implements PipeTransform { + + transform(value: PdfTheme): string { + switch (value) { + case PdfTheme.Dark: return translate('preferences.pdf-dark'); + case PdfTheme.Light: return translate('preferences.pdf-light'); + } + } + +} diff --git a/UI/Web/src/app/_pipes/reading-direction.pipe.ts b/UI/Web/src/app/_pipes/reading-direction.pipe.ts new file mode 100644 index 0000000000..0927d8d428 --- /dev/null +++ b/UI/Web/src/app/_pipes/reading-direction.pipe.ts @@ -0,0 +1,18 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {ReadingDirection} from "../_models/preferences/reading-direction"; +import {translate} from "@ngneat/transloco"; + +@Pipe({ + name: 'readingDirection', + standalone: true +}) +export class ReadingDirectionPipe implements PipeTransform { + + transform(value: ReadingDirection): string { + switch (value) { + case ReadingDirection.LeftToRight: return translate('preferences.left-to-right'); + case ReadingDirection.RightToLeft: return translate('preferences.right-to-right'); + } + } + +} diff --git a/UI/Web/src/app/_pipes/reading-mode.pipe.ts b/UI/Web/src/app/_pipes/reading-mode.pipe.ts new file mode 100644 index 0000000000..b805c2fa49 --- /dev/null +++ b/UI/Web/src/app/_pipes/reading-mode.pipe.ts @@ -0,0 +1,19 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {ReaderMode} from "../_models/preferences/reader-mode"; +import {translate} from "@ngneat/transloco"; + +@Pipe({ + name: 'readerMode', + standalone: true +}) +export class ReaderModePipe implements PipeTransform { + + transform(value: ReaderMode): string { + switch (value) { + case ReaderMode.UpDown: return translate('preferences.up-down'); + case ReaderMode.Webtoon: return translate('preferences.webtoon'); + case ReaderMode.LeftRight: return translate('preferences.left-to-right'); + } + } + +} diff --git a/UI/Web/src/app/_pipes/scaling-option.pipe.ts b/UI/Web/src/app/_pipes/scaling-option.pipe.ts new file mode 100644 index 0000000000..b150cbaadd --- /dev/null +++ b/UI/Web/src/app/_pipes/scaling-option.pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {translate} from "@ngneat/transloco"; +import {ScalingOption} from "../_models/preferences/scaling-option"; + +@Pipe({ + name: 'scalingOption', + standalone: true +}) +export class ScalingOptionPipe implements PipeTransform { + + transform(value: ScalingOption): string { + switch (value) { + case ScalingOption.Automatic: return translate('preferences.automatic'); + case ScalingOption.FitToHeight: return translate('preferences.fit-to-height'); + case ScalingOption.FitToWidth: return translate('preferences.fit-to-width'); + case ScalingOption.Original: return translate('preferences.original'); + } + } + +} diff --git a/UI/Web/src/app/_single-module/scrobble-event-type.pipe.ts b/UI/Web/src/app/_pipes/scrobble-event-type.pipe.ts similarity index 100% rename from UI/Web/src/app/_single-module/scrobble-event-type.pipe.ts rename to UI/Web/src/app/_pipes/scrobble-event-type.pipe.ts diff --git a/UI/Web/src/app/_pipes/scrobble-provider-name.pipe.ts b/UI/Web/src/app/_pipes/scrobble-provider-name.pipe.ts new file mode 100644 index 0000000000..7617f04ec8 --- /dev/null +++ b/UI/Web/src/app/_pipes/scrobble-provider-name.pipe.ts @@ -0,0 +1,19 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {ScrobbleProvider} from "../_services/scrobbling.service"; + +@Pipe({ + name: 'scrobbleProviderName', + standalone: true +}) +export class ScrobbleProviderNamePipe implements PipeTransform { + + transform(value: ScrobbleProvider): string { + switch (value) { + case ScrobbleProvider.AniList: return 'AniList'; + case ScrobbleProvider.Mal: return 'MAL'; + case ScrobbleProvider.Kavita: return 'Kavita'; + case ScrobbleProvider.GoogleBooks: return 'Google Books'; + } + } + +} diff --git a/UI/Web/src/app/_pipes/setting-fragment.pipe.ts b/UI/Web/src/app/_pipes/setting-fragment.pipe.ts new file mode 100644 index 0000000000..8de6576a53 --- /dev/null +++ b/UI/Web/src/app/_pipes/setting-fragment.pipe.ts @@ -0,0 +1,17 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component"; +import {translate} from "@ngneat/transloco"; + +/** + * Translates the fragment for Settings to a User title + */ +@Pipe({ + name: 'settingFragment', + standalone: true +}) +export class SettingFragmentPipe implements PipeTransform { + + transform(tabID: SettingsTabId | string): string { + return translate('settings.' + tabID); + } +} diff --git a/UI/Web/src/app/_pipes/writing-style.pipe.ts b/UI/Web/src/app/_pipes/writing-style.pipe.ts new file mode 100644 index 0000000000..f1680bb721 --- /dev/null +++ b/UI/Web/src/app/_pipes/writing-style.pipe.ts @@ -0,0 +1,18 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {translate} from "@ngneat/transloco"; +import {WritingStyle} from "../_models/preferences/writing-style"; + +@Pipe({ + name: 'writingStyle', + standalone: true +}) +export class WritingStylePipe implements PipeTransform { + + transform(value: WritingStyle): string { + switch (value) { + case WritingStyle.Horizontal: return translate('preferences.horizontal'); + case WritingStyle.Vertical: return translate('preferences.vertical'); + } + } + +} diff --git a/UI/Web/src/app/_routes/admin-routing.module.ts b/UI/Web/src/app/_routes/admin-routing.module.ts deleted file mode 100644 index 83918ccf2d..0000000000 --- a/UI/Web/src/app/_routes/admin-routing.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Routes } from '@angular/router'; -import { AdminGuard } from '../_guards/admin.guard'; -import { DashboardComponent } from '../admin/dashboard/dashboard.component'; - -export const routes: Routes = [ - {path: '**', component: DashboardComponent, pathMatch: 'full', canActivate: [AdminGuard]}, - { - path: '', - runGuardsAndResolvers: 'always', - canActivate: [AdminGuard], - children: [ - {path: 'dashboard', component: DashboardComponent}, - ] - } -]; - diff --git a/UI/Web/src/app/_routes/settings-routing.module.ts b/UI/Web/src/app/_routes/settings-routing.module.ts new file mode 100644 index 0000000000..83cb040efe --- /dev/null +++ b/UI/Web/src/app/_routes/settings-routing.module.ts @@ -0,0 +1,6 @@ +import { Routes } from '@angular/router'; +import {SettingsComponent} from "../settings/_components/settings/settings.component"; + +export const routes: Routes = [ + {path: '', component: SettingsComponent, pathMatch: 'full'}, +]; diff --git a/UI/Web/src/app/_routes/user-settings-routing.module.ts b/UI/Web/src/app/_routes/user-settings-routing.module.ts deleted file mode 100644 index a099acec78..0000000000 --- a/UI/Web/src/app/_routes/user-settings-routing.module.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Routes } from '@angular/router'; -import { UserPreferencesComponent } from '../user-settings/user-preferences/user-preferences.component'; - -export const routes: Routes = [ - {path: '', component: UserPreferencesComponent, pathMatch: 'full'}, -]; diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 31d61353c0..f8c47441cb 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -1,6 +1,6 @@ import { HttpClient } from '@angular/common/http'; import {DestroyRef, inject, Injectable } from '@angular/core'; -import {catchError, of, ReplaySubject, throwError} from 'rxjs'; +import {catchError, Observable, of, ReplaySubject, shareReplay, throwError} from 'rxjs'; import {filter, map, switchMap, tap} from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import { Preferences } from '../_models/preferences/preferences'; @@ -42,6 +42,10 @@ export class AccountService { // Stores values, when someone subscribes gives (1) of last values seen. private currentUserSource = new ReplaySubject(1); public currentUser$ = this.currentUserSource.asObservable(); + public isAdmin$: Observable = this.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), map(u => { + if (!u) return false; + return this.hasAdminRole(u); + }), shareReplay({bufferSize: 1, refCount: true})); private hasValidLicenseSource = new ReplaySubject(1); /** @@ -74,6 +78,17 @@ export class AccountService { }); } + hasAnyRole(user: User, roles: Array) { + if (!user || !user.roles) { + return false; + } + if (roles.length === 0) { + return true; + } + + return roles.some(role => user.roles.includes(role)); + } + hasAdminRole(user: User) { return user && user.roles.includes(Role.Admin); } diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 8ee9deef5a..c4eba8ca9d 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -102,7 +102,11 @@ export enum Action { * Promotes the underlying item (Reading List, Collection) */ Promote = 24, - UnPromote = 25 + UnPromote = 25, + /** + * Invoke a refresh covers as false to generate colorscapes + */ + GenerateColorScape = 26, } /** @@ -245,6 +249,13 @@ export class ActionFactoryService { requiresAdmin: true, children: [], }, + { + action: Action.GenerateColorScape, + title: 'generate-colorscape', + callback: this.dummyCallback, + requiresAdmin: true, + children: [], + }, { action: Action.AnalyzeFiles, title: 'analyze-files', diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 938b703150..3d290f9c53 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -84,23 +84,25 @@ export class ActionService implements OnDestroy { * Request a refresh of Metadata for a given Library * @param library Partial Library, must have id and name populated * @param callback Optional callback to perform actions after API completes + * @param forceUpdate Optional Should we force * @returns */ - async refreshMetadata(library: Partial, callback?: LibraryActionCallback) { + async refreshMetadata(library: Partial, callback?: LibraryActionCallback, forceUpdate: boolean = true) { if (!library.hasOwnProperty('id') || library.id === undefined) { return; } - if (!await this.confirmService.confirm(translate('toasts.confirm-regen-covers'))) { - if (callback) { - callback(library); + // Prompt the user if we are doing a forced call + if (forceUpdate) { + if (!await this.confirmService.confirm(translate('toasts.confirm-regen-covers'))) { + if (callback) { + callback(library); + } + return; } - return; } - const forceUpdate = true; //await this.promptIfForce(); - - this.libraryService.refreshMetadata(library?.id, forceUpdate).pipe(take(1)).subscribe((res: any) => { + this.libraryService.refreshMetadata(library?.id, forceUpdate).subscribe((res: any) => { this.toastr.info(translate('toasts.scan-queued', {name: library.name})); if (callback) { callback(library); @@ -467,7 +469,7 @@ export class ActionService implements OnDestroy { this.readingListModalRef.componentInstance.seriesId = seriesId; this.readingListModalRef.componentInstance.volumeIds = volumes.map(v => v.id); this.readingListModalRef.componentInstance.chapterIds = chapters?.map(c => c.id); - this.readingListModalRef.componentInstance.title = 'Multiple Selections'; + this.readingListModalRef.componentInstance.title = translate('action.multiple-selections'); this.readingListModalRef.componentInstance.type = ADD_FLOW.Multiple; @@ -507,7 +509,7 @@ export class ActionService implements OnDestroy { if (this.readingListModalRef != null) { return; } this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); this.readingListModalRef.componentInstance.seriesIds = series.map(v => v.id); - this.readingListModalRef.componentInstance.title = 'Multiple Selections'; + this.readingListModalRef.componentInstance.title = translate('action.multiple-selections'); this.readingListModalRef.componentInstance.type = ADD_FLOW.Multiple_Series; @@ -535,7 +537,7 @@ export class ActionService implements OnDestroy { 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 = 'New Collection'; + this.collectionModalRef.componentInstance.title = translate('action.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 new file mode 100644 index 0000000000..9ea2a845f5 --- /dev/null +++ b/UI/Web/src/app/_services/colorscape.service.ts @@ -0,0 +1,386 @@ +import { Injectable, Inject } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { BehaviorSubject } from 'rxjs'; + +interface ColorSpace { + primary: string; + lighter: string; + darker: string; + complementary: string; +} + +interface ColorSpaceRGBA { + primary: RGBAColor; + lighter: RGBAColor; + darker: RGBAColor; + complementary: RGBAColor; +} + +interface RGBAColor { + r: number; + g: number; + b: number; + a: number; +} + +interface RGB { + r: number; + g: number; + b: number; +} + +const colorScapeSelector = 'colorscape'; + +/** + * ColorScape handles setting the scape and managing the transitions + */ +@Injectable({ + providedIn: 'root' +}) +export class ColorscapeService { + private colorSubject = new BehaviorSubject(null); + public colors$ = this.colorSubject.asObservable(); + + private minDuration = 1000; // minimum duration + private maxDuration = 4000; // maximum duration + + constructor(@Inject(DOCUMENT) private document: Document) { + + } + + /** + * Sets a color scape for the active theme + * @param primaryColor + * @param complementaryColor + */ + setColorScape(primaryColor: string, complementaryColor: string | null = null) { + if (this.getCssVariable('--colorscape-enabled') === 'false') { + return; + } + + const elem = this.document.querySelector('#backgroundCanvas'); + + if (!elem) { + return; + } + + const newColors: ColorSpace = primaryColor ? + this.generateBackgroundColors(primaryColor, complementaryColor, this.isDarkTheme()) : + this.defaultColors(); + + const newColorsRGBA = this.convertColorsToRGBA(newColors); + const oldColors = this.colorSubject.getValue() || this.convertColorsToRGBA(this.defaultColors()); + const duration = this.calculateTransitionDuration(oldColors, newColorsRGBA); + + + // Check if the colors we are transitioning to are visually equal + if (this.areColorSpacesVisuallyEqual(oldColors, newColorsRGBA)) { + return; + } + + this.animateColorTransition(oldColors, newColorsRGBA, duration); + + this.colorSubject.next(newColorsRGBA); + } + + private areColorSpacesVisuallyEqual(color1: ColorSpaceRGBA, color2: ColorSpaceRGBA, threshold: number = 0): boolean { + return this.areRGBAColorsVisuallyEqual(color1.primary, color2.primary, threshold) && + this.areRGBAColorsVisuallyEqual(color1.lighter, color2.lighter, threshold) && + this.areRGBAColorsVisuallyEqual(color1.darker, color2.darker, threshold) && + this.areRGBAColorsVisuallyEqual(color1.complementary, color2.complementary, threshold); + } + + private areRGBAColorsVisuallyEqual(color1: RGBAColor, color2: RGBAColor, threshold: number = 0): boolean { + return Math.abs(color1.r - color2.r) <= threshold && + Math.abs(color1.g - color2.g) <= threshold && + Math.abs(color1.b - color2.b) <= threshold && + Math.abs(color1.a - color2.a) <= threshold / 255; + } + + private convertColorsToRGBA(colors: ColorSpace): ColorSpaceRGBA { + return { + primary: this.parseColorToRGBA(colors.primary), + lighter: this.parseColorToRGBA(colors.lighter), + darker: this.parseColorToRGBA(colors.darker), + complementary: this.parseColorToRGBA(colors.complementary) + }; + } + + private parseColorToRGBA(color: string): RGBAColor { + if (color.startsWith('#')) { + return this.hexToRGBA(color); + } else if (color.startsWith('rgb')) { + return this.rgbStringToRGBA(color); + } else { + console.warn(`Unsupported color format: ${color}. Defaulting to black.`); + return { r: 0, g: 0, b: 0, a: 1 }; + } + } + + private hexToRGBA(hex: string, opacity: number = 1): RGBAColor { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + a: opacity + } + : { r: 0, g: 0, b: 0, a: opacity }; + } + + private rgbStringToRGBA(rgb: string): RGBAColor { + const matches = rgb.match(/(\d+(\.\d+)?)/g); + if (matches) { + return { + r: parseInt(matches[0], 10), + g: parseInt(matches[1], 10), + b: parseInt(matches[2], 10), + a: matches.length === 4 ? parseFloat(matches[3]) : 1 + }; + } + return { r: 0, g: 0, b: 0, a: 1 }; + } + + private calculateTransitionDuration(oldColors: ColorSpaceRGBA, newColors: ColorSpaceRGBA): number { + const colorKeys: (keyof ColorSpaceRGBA)[] = ['primary', 'lighter', 'darker', 'complementary']; + let totalDistance = 0; + + for (const key of colorKeys) { + const oldRGB = this.rgbaToRgb(oldColors[key]); + const newRGB = this.rgbaToRgb(newColors[key]); + totalDistance += this.calculateColorDistance(oldRGB, newRGB); + } + + // Normalize the total distance and map it to our duration range + const normalizedDistance = Math.min(totalDistance / (255 * 3 * 4), 1); // Max possible distance is 255*3*4 + const duration = this.minDuration + normalizedDistance * (this.maxDuration - this.minDuration); + + return Math.round(duration); + } + + private rgbaToRgb(rgba: RGBAColor): RGB { + return { r: rgba.r, g: rgba.g, b: rgba.b }; + } + + private calculateColorDistance(rgb1: RGB, rgb2: RGB): number { + return Math.sqrt( + Math.pow(rgb2.r - rgb1.r, 2) + + Math.pow(rgb2.g - rgb1.g, 2) + + Math.pow(rgb2.b - rgb1.b, 2) + ); + } + + + private defaultColors() { + return { + primary: this.getCssVariable('--colorscape-primary-default-color'), + lighter: this.getCssVariable('--colorscape-lighter-default-color'), + darker: this.getCssVariable('--colorscape-darker-default-color'), + complementary: this.getCssVariable('--colorscape-complementary-default-color'), + } + } + + private animateColorTransition(oldColors: ColorSpaceRGBA, newColors: ColorSpaceRGBA, duration: number) { + const startTime = performance.now(); + + const animate = (currentTime: number) => { + const elapsedTime = currentTime - startTime; + const progress = Math.min(elapsedTime / duration, 1); + + const interpolatedColors: ColorSpaceRGBA = { + primary: this.interpolateRGBAColor(oldColors.primary, newColors.primary, progress), + lighter: this.interpolateRGBAColor(oldColors.lighter, newColors.lighter, progress), + darker: this.interpolateRGBAColor(oldColors.darker, newColors.darker, progress), + complementary: this.interpolateRGBAColor(oldColors.complementary, newColors.complementary, progress) + }; + + this.setColorsImmediately(interpolatedColors); + + if (progress < 1) { + requestAnimationFrame(animate); + } + }; + + requestAnimationFrame(animate); + } + + private interpolateRGBAColor(color1: RGBAColor, color2: RGBAColor, progress: number): RGBAColor { + return { + r: Math.round(color1.r + (color2.r - color1.r) * progress), + g: Math.round(color1.g + (color2.g - color1.g) * progress), + b: Math.round(color1.b + (color2.b - color1.b) * progress), + a: color1.a + (color2.a - color1.a) * progress + }; + } + + 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 })}; + } + `); + } + + private generateBackgroundColors(primaryColor: string, secondaryColor: string | null = null, isDarkTheme: boolean = true): ColorSpace { + const primary = this.hexToRgb(primaryColor); + const secondary = secondaryColor ? this.hexToRgb(secondaryColor) : this.calculateComplementaryRgb(primary); + + const primaryHSL = this.rgbToHsl(primary); + const secondaryHSL = this.rgbToHsl(secondary); + + if (isDarkTheme) { + const lighterHSL = this.adjustHue(secondaryHSL, 30); + lighterHSL.s = Math.min(lighterHSL.s + 0.2, 1); + lighterHSL.l = Math.min(lighterHSL.l + 0.1, 0.6); + + const darkerHSL = { ...primaryHSL }; + darkerHSL.l = Math.max(darkerHSL.l - 0.3, 0.1); + + const complementaryHSL = this.adjustHue(primaryHSL, 180); + complementaryHSL.s = Math.min(complementaryHSL.s + 0.1, 1); + complementaryHSL.l = Math.max(complementaryHSL.l - 0.2, 0.2); + + return { + primary: this.rgbToHex(primary), + lighter: this.rgbToHex(this.hslToRgb(lighterHSL)), + darker: this.rgbToHex(this.hslToRgb(darkerHSL)), + complementary: this.rgbToHex(this.hslToRgb(complementaryHSL)) + }; + } else { + // NOTE: Light themes look bad in general with this system. + const lighterHSL = { ...primaryHSL }; + lighterHSL.s = Math.max(lighterHSL.s - 0.3, 0); + lighterHSL.l = Math.min(lighterHSL.l + 0.5, 0.95); + + const darkerHSL = { ...primaryHSL }; + darkerHSL.s = Math.max(darkerHSL.s - 0.1, 0); + darkerHSL.l = Math.min(darkerHSL.l + 0.3, 0.9); + + const complementaryHSL = this.adjustHue(primaryHSL, 180); + complementaryHSL.s = Math.max(complementaryHSL.s - 0.2, 0); + complementaryHSL.l = Math.min(complementaryHSL.l + 0.4, 0.9); + + return { + primary: this.rgbToHex(primary), + lighter: this.rgbToHex(this.hslToRgb(lighterHSL)), + darker: this.rgbToHex(this.hslToRgb(darkerHSL)), + complementary: this.rgbToHex(this.hslToRgb(complementaryHSL)) + }; + } + } + + private hexToRgb(hex: string): RGB { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : { r: 0, g: 0, b: 0 }; + } + + private rgbToHex(rgb: RGB): string { + return `#${((1 << 24) + (rgb.r << 16) + (rgb.g << 8) + rgb.b).toString(16).slice(1)}`; + } + + private rgbToHsl(rgb: RGB): { h: number; s: number; l: number } { + const r = rgb.r / 255; + const g = rgb.g / 255; + const b = rgb.b / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h = 0; + let s = 0; + const l = (max + min) / 2; + + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + h /= 6; + } + + return { h, s, l }; + } + + private hslToRgb(hsl: { h: number; s: number; l: number }): RGB { + const { h, s, l } = hsl; + let r, g, b; + + if (s === 0) { + r = g = b = l; + } else { + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1/6) return p + (q - p) * 6 * t; + if (t < 1/2) return q; + if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; + return p; + }; + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1/3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1/3); + } + + return { + r: Math.round(r * 255), + g: Math.round(g * 255), + b: Math.round(b * 255) + }; + } + + private adjustHue(hsl: { h: number; s: number; l: number }, amount: number): { h: number; s: number; l: number } { + return { + h: (hsl.h + amount / 360) % 1, + s: hsl.s, + l: hsl.l + }; + } + + private calculateComplementaryRgb(rgb: RGB): RGB { + const hsl = this.rgbToHsl(rgb); + const complementaryHsl = this.adjustHue(hsl, 180); + return this.hslToRgb(complementaryHsl); + } + + private rgbaToString(color: RGBAColor): string { + return `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`; + } + + private getCssVariable(variableName: string): string { + return getComputedStyle(this.document.body).getPropertyValue(variableName).trim(); + } + + private isDarkTheme(): boolean { + return getComputedStyle(this.document.body).getPropertyValue('--color-scheme').trim().toLowerCase() === 'dark'; + } + + private injectStyleElement(id: string, styles: string) { + let styleElement = this.document.getElementById(id); + if (!styleElement) { + styleElement = this.document.createElement('style'); + styleElement.id = id; + this.document.head.appendChild(styleElement); + } + styleElement.textContent = styles; + } + + private unsetPageColorOverrides() { + Array.from(this.document.head.children).filter(el => el.tagName === 'STYLE' && el.id.toLowerCase() === colorScapeSelector).forEach(c => this.document.head.removeChild(c)); + } +} diff --git a/UI/Web/src/app/_services/device.service.ts b/UI/Web/src/app/_services/device.service.ts index 1ba491177c..496abf9c2b 100644 --- a/UI/Web/src/app/_services/device.service.ts +++ b/UI/Web/src/app/_services/device.service.ts @@ -33,11 +33,11 @@ export class DeviceService { } createDevice(name: string, platform: DevicePlatform, emailAddress: string) { - return this.httpClient.post(this.baseUrl + 'device/create', {name, platform, emailAddress}, TextResonse); + return this.httpClient.post(this.baseUrl + 'device/create', {name, platform, emailAddress}); } updateDevice(id: number, name: string, platform: DevicePlatform, emailAddress: string) { - return this.httpClient.post(this.baseUrl + 'device/update', {id, name, platform, emailAddress}, TextResonse); + return this.httpClient.post(this.baseUrl + 'device/update', {id, name, platform, emailAddress}); } deleteDevice(id: number) { diff --git a/UI/Web/src/app/_services/jumpbar.service.ts b/UI/Web/src/app/_services/jumpbar.service.ts index 6ae2cb2e26..e4ac0b01f0 100644 --- a/UI/Web/src/app/_services/jumpbar.service.ts +++ b/UI/Web/src/app/_services/jumpbar.service.ts @@ -20,8 +20,8 @@ export class JumpbarService { return ''; } - getResumePosition(key: string) { - if (this.resumeScroll.hasOwnProperty(key)) return this.resumeScroll[key]; + getResumePosition(url: string) { + if (this.resumeScroll.hasOwnProperty(url)) return this.resumeScroll[url]; return 0; } @@ -29,8 +29,8 @@ export class JumpbarService { this.resumeKeys[key] = value; } - saveScrollOffset(key: string, value: number) { - this.resumeScroll[key] = value; + saveResumePosition(url: string, value: number) { + this.resumeScroll[url] = value; } generateJumpBar(jumpBarKeys: Array, currentSize: number) { @@ -93,10 +93,10 @@ export class JumpbarService { } /** - * + * * @param data An array of objects * @param keySelector A method to fetch a string from the object, which is used to classify the JumpKey - * @returns + * @returns */ getJumpKeys(data :Array, keySelector: (data: any) => string) { const keys: {[key: string]: number} = {}; diff --git a/UI/Web/src/app/_services/nav.service.ts b/UI/Web/src/app/_services/nav.service.ts index 53eaac7fd5..7a2fa2f5af 100644 --- a/UI/Web/src/app/_services/nav.service.ts +++ b/UI/Web/src/app/_services/nav.service.ts @@ -1,18 +1,25 @@ import { DOCUMENT } from '@angular/common'; -import { Inject, Injectable, Renderer2, RendererFactory2 } from '@angular/core'; -import { ReplaySubject, take } from 'rxjs'; +import {DestroyRef, inject, Inject, Injectable, OnDestroy, Renderer2, RendererFactory2} from '@angular/core'; +import {filter, ReplaySubject, Subject, take} from 'rxjs'; import {HttpClient} from "@angular/common/http"; import {environment} from "../../environments/environment"; import {SideNavStream} from "../_models/sidenav/sidenav-stream"; import {TextResonse} from "../_types/text-response"; import {DashboardStream} from "../_models/dashboard/dashboard-stream"; import {AccountService} from "./account.service"; -import {tap} from "rxjs/operators"; +import {map, tap} from "rxjs/operators"; +import {NavigationEnd, Router} from "@angular/router"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @Injectable({ providedIn: 'root' }) export class NavService { + + private readonly accountService = inject(AccountService); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + public localStorageSideNavKey = 'kavita--sidenav--expanded'; private navbarVisibleSource = new ReplaySubject(1); @@ -33,10 +40,22 @@ export class NavService { */ sideNavVisibility$ = this.sideNavVisibilitySource.asObservable(); + usePreferenceSideNav$ = this.router.events.pipe( + filter(event => event instanceof NavigationEnd), + map((evt) => { + const event = (evt as NavigationEnd); + const url = event.urlAfterRedirects || event.url; + return ( + /\/admin\/dashboard(#.*)?/.test(url) || /\/preferences(\/[^\/]+|#.*)?/.test(url) || /\/settings(\/[^\/]+|#.*)?/.test(url) + ); + }), + takeUntilDestroyed(this.destroyRef), + ); + private renderer: Renderer2; baseUrl = environment.apiUrl; - constructor(@Inject(DOCUMENT) private document: Document, rendererFactory: RendererFactory2, private httpClient: HttpClient, private accountService: AccountService) { + constructor(@Inject(DOCUMENT) private document: Document, rendererFactory: RendererFactory2, private httpClient: HttpClient) { this.renderer = rendererFactory.createRenderer(null, null); // To avoid flashing, let's check if we are authenticated before we show @@ -79,9 +98,9 @@ export class NavService { * Shows the top nav bar. This should be visible on all pages except the reader. */ showNavBar() { - this.renderer.setStyle(this.document.querySelector('body'), 'margin-top', '56px'); - this.renderer.setStyle(this.document.querySelector('body'), 'height', 'calc(var(--vh)*100 - 56px)'); - this.renderer.setStyle(this.document.querySelector('html'), 'height', 'calc(var(--vh)*100 - 56px)'); + this.renderer.setStyle(this.document.querySelector('body'), 'margin-top', 'var(--nav-offset)'); + this.renderer.setStyle(this.document.querySelector('body'), 'height', 'calc(var(--vh)*100 - var(--nav-offset))'); + this.renderer.setStyle(this.document.querySelector('html'), 'height', 'calc(var(--vh)*100 - var(--nav-offset))'); this.navbarVisibleSource.next(true); } @@ -117,4 +136,9 @@ export class NavService { localStorage.setItem(this.localStorageSideNavKey, newVal + ''); }); } + + collapseSideNav(state: boolean) { + this.sideNavCollapseSource.next(state); + localStorage.setItem(this.localStorageSideNavKey, state + ''); + } } diff --git a/UI/Web/src/app/_services/theme.service.ts b/UI/Web/src/app/_services/theme.service.ts index 827f93b9a6..b977e22ba3 100644 --- a/UI/Web/src/app/_services/theme.service.ts +++ b/UI/Web/src/app/_services/theme.service.ts @@ -1,9 +1,17 @@ import {DOCUMENT} from '@angular/common'; import {HttpClient} from '@angular/common/http'; -import {DestroyRef, inject, Inject, Injectable, Renderer2, RendererFactory2, SecurityContext} from '@angular/core'; +import { + DestroyRef, + inject, + Inject, + Injectable, + Renderer2, + RendererFactory2, + SecurityContext +} from '@angular/core'; import {DomSanitizer} from '@angular/platform-browser'; import {ToastrService} from 'ngx-toastr'; -import {map, ReplaySubject, take} from 'rxjs'; +import {filter, map, ReplaySubject, take, tap} from 'rxjs'; import {environment} from 'src/environments/environment'; import {ConfirmService} from '../shared/confirm.service'; import {NotificationProgressEvent} from '../_models/events/notification-progress-event'; @@ -15,7 +23,9 @@ import {translate} from "@ngneat/transloco"; import {DownloadableSiteTheme} from "../_models/theme/downloadable-site-theme"; import {NgxFileDropEntry} from "ngx-file-drop"; import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event"; - +import {NavigationEnd, Router} from "@angular/router"; +import {ColorscapeService} from "./colorscape.service"; +import {ColorScape} from "../_models/theme/colorscape"; @Injectable({ providedIn: 'root' @@ -23,6 +33,8 @@ import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event" export class ThemeService { private readonly destroyRef = inject(DestroyRef); + private readonly colorTransitionService = inject(ColorscapeService); + public defaultTheme: string = 'dark'; public defaultBookTheme: string = 'Dark'; @@ -42,9 +54,16 @@ export class ThemeService { constructor(rendererFactory: RendererFactory2, @Inject(DOCUMENT) private document: Document, private httpClient: HttpClient, - messageHub: MessageHubService, private domSanitizer: DomSanitizer, private confirmService: ConfirmService, private toastr: ToastrService) { + messageHub: MessageHubService, private domSanitizer: DomSanitizer, private confirmService: ConfirmService, private toastr: ToastrService, + private router: Router) { this.renderer = rendererFactory.createRenderer(null, null); + this.router.events.pipe( + filter(event => event instanceof NavigationEnd) + ).subscribe(() => { + this.setColorScape(''); + }); + messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(message => { if (message.event === EVENTS.NotificationProgress) { @@ -90,21 +109,21 @@ export class ThemeService { return getComputedStyle(this.document.body).getPropertyValue('--color-scheme').trim(); } - /** - * --theme-color from theme. Updates the meta tag - * @returns - */ - getThemeColor() { - return getComputedStyle(this.document.body).getPropertyValue('--theme-color').trim(); - } + /** + * --theme-color from theme. Updates the meta tag + * @returns + */ + getThemeColor() { + return getComputedStyle(this.document.body).getPropertyValue('--theme-color').trim(); + } - /** - * --msapplication-TileColor from theme. Updates the meta tag - * @returns - */ - getTileColor() { - return getComputedStyle(this.document.body).getPropertyValue('--title-color').trim(); - } + /** + * --msapplication-TileColor from theme. Updates the meta tag + * @returns + */ + getTileColor() { + return getComputedStyle(this.document.body).getPropertyValue('--title-color').trim(); + } getCssVariable(variable: string) { return getComputedStyle(this.document.body).getPropertyValue(variable).trim(); @@ -166,6 +185,26 @@ export class ThemeService { } + /** + * Set's the background color from a single primary color. + * @param primaryColor + * @param complementaryColor + */ + setColorScape(primaryColor: string, complementaryColor: string | null = null) { + this.colorTransitionService.setColorScape(primaryColor, complementaryColor); + } + + /** + * Trigger a request to get the colors for a given entity and apply them + * @param entity + * @param id + */ + refreshColorScape(entity: 'series' | 'volume' | 'chapter', id: number) { + return this.httpClient.get(`${this.baseUrl}colorscape/${entity}?id=${id}`).pipe(tap((cs) => { + this.setColorScape(cs.primary || '', cs.secondary); + })); + } + /** * Sets the theme as active. Will inject a style tag into document to load a custom theme and apply the selector to the body * @param themeName @@ -187,7 +226,6 @@ export class ThemeService { const styleElem = this.document.createElement('style'); styleElem.id = 'theme-' + theme.name; styleElem.appendChild(this.document.createTextNode(content)); - this.renderer.appendChild(this.document.head, styleElem); // Check if the theme has --theme-color and apply it to meta tag @@ -238,6 +276,4 @@ export class ThemeService { private unsetBookThemes() { Array.from(this.document.body.classList).filter(cls => cls.startsWith('brtheme-')).forEach(c => this.document.body.classList.remove(c)); } - - } diff --git a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html index f2452ea196..c033b535b3 100644 --- a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html +++ b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html @@ -1,5 +1,5 @@ - + @if (actions.length > 0) {
@@ -8,33 +8,33 @@
- + @for(action of list; track action.id) { - - - - + @if (action.children === undefined || action?.children?.length === 0 || action.dynamicList !== undefined) { + @if (action.dynamicList !== undefined && (action.dynamicList | async | dynamicList); as dList) { + @for(dynamicItem of dList; track dynamicItem.title) { - - - - - - - - - - -
- + } + } @else if (willRenderAction(action)) { + + } + } @else { + @if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async)) { + +
+ @if (willRenderAction(action)) { + + }
- - - + } + } + } - - + } diff --git a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.scss b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.scss index 5768c28f8f..34ff10fe62 100644 --- a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.scss +++ b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.scss @@ -26,3 +26,9 @@ float: right; padding: var(--bs-dropdown-item-padding-y) 0; } + +// Robbie added this but it broke most of the uses +//.dropdown-toggle { +// padding-top: 0; +// padding-bottom: 0; +//} diff --git a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts index 9dd1773d7a..57003c3a9f 100644 --- a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts +++ b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts @@ -11,7 +11,7 @@ import { import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '@ng-bootstrap/ng-bootstrap'; import { AccountService } from 'src/app/_services/account.service'; import { Action, ActionItem } from 'src/app/_services/action-factory.service'; -import {CommonModule} from "@angular/common"; +import {AsyncPipe, CommonModule, NgTemplateOutlet} from "@angular/common"; import {TranslocoDirective} from "@ngneat/transloco"; import {DynamicListPipe} from "./_pipes/dynamic-list.pipe"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @@ -19,7 +19,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @Component({ selector: 'app-card-actionables', standalone: true, - imports: [CommonModule, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, DynamicListPipe, TranslocoDirective], + imports: [NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, DynamicListPipe, TranslocoDirective, AsyncPipe, NgTemplateOutlet], templateUrl: './card-actionables.component.html', styleUrls: ['./card-actionables.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/_single-module/review-card/review-card.component.scss b/UI/Web/src/app/_single-module/review-card/review-card.component.scss index 6fa132b84f..64485c3dd5 100644 --- a/UI/Web/src/app/_single-module/review-card/review-card.component.scss +++ b/UI/Web/src/app/_single-module/review-card/review-card.component.scss @@ -1,5 +1,5 @@ .profile-image { - font-size: 2rem; + font-size: 1.2rem; padding: 20px; } diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html index 5387005d2f..b575a7e748 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html @@ -21,12 +21,9 @@
{{t('title')}}
}
- +
- @@ -45,66 +42,62 @@
{{t('title')}}
- @if (events.length === 0) { - - - - } - - - - - - - + + + + + - - + + {{item.isProcessed ? t('processed') : t('not-processed')}} + + + + } @empty { + + } +
- {{t('created-header')}} - {{t('last-modified-header')}}
{{t('no-data')}}
- {{item.createdUtc | utcToLocalTime | defaultValue}} - - {{item.lastModifiedUtc | utcToLocalTime | defaultValue }} - - {{item.scrobbleEventType | scrobbleEventType}} - - {{item.seriesName}} - - @switch (item.scrobbleEventType) { - @case (ScrobbleEventType.ChapterRead) { - @if(item.volumeNumber === LooseLeafOrDefaultNumber) { - @if (item.chapterNumber === LooseLeafOrDefaultNumber) { - {{t('special')}} - } @else { - {{t('chapter-num', {num: item.chapterNumber})}} + @for(item of events; track item; let idx = $index) { +
+ {{item.lastModifiedUtc | utcToLocalTime | defaultValue }} + + {{item.scrobbleEventType | scrobbleEventType}} + + {{item.seriesName}} + + @switch (item.scrobbleEventType) { + @case (ScrobbleEventType.ChapterRead) { + @if(item.volumeNumber === LooseLeafOrDefaultNumber) { + @if (item.chapterNumber === LooseLeafOrDefaultNumber) { + {{t('special')}} + } @else { + {{t('chapter-num', {num: item.chapterNumber})}} + } + } + @else if (item.chapterNumber === LooseLeafOrDefaultNumber) { + {{t('volume-num', {num: item.volumeNumber})}} + } + @else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) { + Special + } + @else { + {{t('volume-and-chapter-num', {v: item.volumeNumber, n: item.chapterNumber})}} + } + } + @case (ScrobbleEventType.ScoreUpdated) { + {{t('rating', {r: item.rating})}} + } + @default { + {{t('not-applicable')}} } } - @else if (item.chapterNumber === LooseLeafOrDefaultNumber) { - {{t('volume-num', {num: item.volumeNumber})}} - } - @else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) { - Special - } - @else { - {{t('volume-and-chapter-num', {v: item.volumeNumber, n: item.chapterNumber})}} + + @if(item.isProcessed) { + + } @else if (item.isErrored) { + + } @else { + } - } - @case (ScrobbleEventType.ScoreUpdated) { - {{t('rating', {r: item.rating})}} - } - @default { - {{t('not-applicable')}} - } - } - - @if(item.isProcessed) { - - } @else if (item.isErrored) { - - } @else { - - } - - {{item.isProcessed ? t('processed') : t('not-processed')}} - -
{{t('no-data')}}
diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts index 88fa503e07..34dcad9f12 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts @@ -4,7 +4,7 @@ import {CommonModule} from '@angular/common'; import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.service"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {ScrobbleEvent, ScrobbleEventType} from "../../_models/scrobbling/scrobble-event"; -import {ScrobbleEventTypePipe} from "../scrobble-event-type.pipe"; +import {ScrobbleEventTypePipe} from "../../_pipes/scrobble-event-type.pipe"; import {NgbPagination, NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {ScrobbleEventSortField} from "../../_models/scrobbling/scrobble-event-filter"; import {debounceTime, take} from "rxjs/operators"; diff --git a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.scss b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.scss index bbe577134f..a086f4ecf6 100644 --- a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.scss +++ b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.scss @@ -14,12 +14,8 @@ $breadcrumb-divider: quote(">"); border: 1px solid #ced4da; } -.table { - background-color: lightgrey; -} - .disabled { color: lightgrey !important; cursor: not-allowed !important; background-color: var(--error-color); -} \ No newline at end of file +} diff --git a/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts b/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts index 6d1ddd1057..5ea4ac0e92 100644 --- a/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts +++ b/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts @@ -3,10 +3,10 @@ import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {Library} from 'src/app/_models/library/library'; import {Member} from 'src/app/_models/auth/member'; import {LibraryService} from 'src/app/_services/library.service'; -import {SelectionModel} from 'src/app/typeahead/_components/typeahead.component'; import {NgFor, NgIf} from '@angular/common'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {TranslocoDirective} from "@ngneat/transloco"; +import {SelectionModel} from "../../../typeahead/_models/selection-model"; @Component({ selector: 'app-library-access-modal', diff --git a/UI/Web/src/app/admin/_models/cover-image-size.ts b/UI/Web/src/app/admin/_models/cover-image-size.ts index 6a58de8ba7..72ae3924c3 100644 --- a/UI/Web/src/app/admin/_models/cover-image-size.ts +++ b/UI/Web/src/app/admin/_models/cover-image-size.ts @@ -1,3 +1,4 @@ + export enum CoverImageSize { Default = 1, Medium = 2, @@ -5,10 +6,6 @@ export enum CoverImageSize { XLarge = 4 } -export const CoverImageSizes = - [ - {value: CoverImageSize.Default, title: 'cover-image-size.default'}, - {value: CoverImageSize.Medium, title: 'cover-image-size.medium'}, - {value: CoverImageSize.Large, title: 'cover-image-size.large'}, - {value: CoverImageSize.XLarge, title: 'cover-image-size.xlarge'} - ]; +export const allCoverImageSizes = Object.keys(CoverImageSize) + .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0) + .map(key => parseInt(key, 10)) as CoverImageSize[]; diff --git a/UI/Web/src/app/admin/_models/encode-format.ts b/UI/Web/src/app/admin/_models/encode-format.ts index 9a386c4f70..c408570ab8 100644 --- a/UI/Web/src/app/admin/_models/encode-format.ts +++ b/UI/Web/src/app/admin/_models/encode-format.ts @@ -4,4 +4,6 @@ export enum EncodeFormat { AVIF = 2 } -export const EncodeFormats = [{value: EncodeFormat.PNG, title: 'PNG'}, {value: EncodeFormat.WebP, title: 'WebP'}, {value: EncodeFormat.AVIF, title: 'AVIF'}]; \ No newline at end of file +export const allEncodeFormats = Object.keys(EncodeFormat) + .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0) + .map(key => parseInt(key, 10)) as EncodeFormat[]; diff --git a/UI/Web/src/app/admin/_models/media-error.ts b/UI/Web/src/app/admin/_models/media-error.ts index 9439949405..39dffca3b7 100644 --- a/UI/Web/src/app/admin/_models/media-error.ts +++ b/UI/Web/src/app/admin/_models/media-error.ts @@ -3,4 +3,5 @@ export interface KavitaMediaError { filePath: string; comment: string; details: string; + createdUtc: string; } diff --git a/UI/Web/src/app/admin/dashboard/dashboard.component.html b/UI/Web/src/app/admin/dashboard/dashboard.component.html deleted file mode 100644 index 9e7302015b..0000000000 --- a/UI/Web/src/app/admin/dashboard/dashboard.component.html +++ /dev/null @@ -1,48 +0,0 @@ - - -

- {{t('title')}} -

-
-
- -
- -
- -
diff --git a/UI/Web/src/app/admin/dashboard/dashboard.component.scss b/UI/Web/src/app/admin/dashboard/dashboard.component.scss deleted file mode 100644 index 602d9fa360..0000000000 --- a/UI/Web/src/app/admin/dashboard/dashboard.component.scss +++ /dev/null @@ -1,10 +0,0 @@ -.container { - padding-top: 10px; -} - -.tab:last-child > a { - &.active, &::before { - background-color: #FFBA15; - } -} - diff --git a/UI/Web/src/app/admin/dashboard/dashboard.component.ts b/UI/Web/src/app/admin/dashboard/dashboard.component.ts deleted file mode 100644 index b3e7ae2cf5..0000000000 --- a/UI/Web/src/app/admin/dashboard/dashboard.component.ts +++ /dev/null @@ -1,88 +0,0 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; -import {ActivatedRoute, RouterLink} from '@angular/router'; -import {Title} from '@angular/platform-browser'; -import {NavService} from '../../_services/nav.service'; -import {SentenceCasePipe} from '../../_pipes/sentence-case.pipe'; -import {LicenseComponent} from '../license/license.component'; -import {ManageTasksSettingsComponent} from '../manage-tasks-settings/manage-tasks-settings.component'; -import {ServerStatsComponent} from '../../statistics/_components/server-stats/server-stats.component'; -import {ManageSystemComponent} from '../manage-system/manage-system.component'; -import {ManageLogsComponent} from '../manage-logs/manage-logs.component'; -import {ManageLibraryComponent} from '../manage-library/manage-library.component'; -import {ManageUsersComponent} from '../manage-users/manage-users.component'; -import {ManageMediaSettingsComponent} from '../manage-media-settings/manage-media-settings.component'; -import {ManageEmailSettingsComponent} from '../manage-email-settings/manage-email-settings.component'; -import {ManageSettingsComponent} from '../manage-settings/manage-settings.component'; -import {NgFor, NgIf} from '@angular/common'; -import {NgbNav, NgbNavContent, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavOutlet} from '@ng-bootstrap/ng-bootstrap'; -import { - SideNavCompanionBarComponent -} from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; -import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco"; -import {WikiLink} from "../../_models/wiki"; - -enum TabID { - General = '', - Email = 'email', - Media = 'media', - Users = 'users', - Libraries = 'libraries', - System = 'system', - Tasks = 'tasks', - Logs = 'logs', - Statistics = 'statistics', - KavitaPlus = 'kavitaplus' -} - -@Component({ - selector: 'app-dashboard', - templateUrl: './dashboard.component.html', - styleUrls: ['./dashboard.component.scss'], - standalone: true, - imports: [SideNavCompanionBarComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, RouterLink, - NgbNavContent, NgIf, ManageSettingsComponent, ManageEmailSettingsComponent, ManageMediaSettingsComponent, - ManageUsersComponent, ManageLibraryComponent, ManageSystemComponent, ServerStatsComponent, - ManageTasksSettingsComponent, LicenseComponent, NgbNavOutlet, SentenceCasePipe, TranslocoDirective], - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class DashboardComponent implements OnInit { - - private readonly cdRef = inject(ChangeDetectorRef); - protected readonly route = inject(ActivatedRoute); - protected readonly navService = inject(NavService); - private readonly titleService = inject(Title); - protected readonly TabID = TabID; - protected readonly WikiLink = WikiLink; - - - tabs: Array<{title: string, fragment: string}> = [ - {title: 'general-tab', fragment: TabID.General}, - {title: 'users-tab', fragment: TabID.Users}, - {title: 'libraries-tab', fragment: TabID.Libraries}, - {title: 'media-tab', fragment: TabID.Media}, - {title: 'email-tab', fragment: TabID.Email}, - {title: 'tasks-tab', fragment: TabID.Tasks}, - {title: 'statistics-tab', fragment: TabID.Statistics}, - {title: 'system-tab', fragment: TabID.System}, - {title: 'kavita+-tab', fragment: TabID.KavitaPlus}, - ]; - active = this.tabs[0]; - - - constructor() { - this.route.fragment.subscribe(frag => { - const tab = this.tabs.filter(item => item.fragment === frag); - if (tab.length > 0) { - this.active = tab[0]; - } else { - this.active = this.tabs[0]; // Default to first tab - } - this.cdRef.markForCheck(); - }); - - } - - ngOnInit() { - this.titleService.setTitle('Kavita - ' + translate('admin-dashboard.title')); - } -} diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.html b/UI/Web/src/app/admin/edit-user/edit-user.component.html index ab570b45a3..f111fc39cd 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.html +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.html @@ -1,7 +1,7 @@