From a847468a6c5c08b0c78fee45e82a43c9a0f9f2d4 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Thu, 31 Oct 2024 18:44:03 -0500 Subject: [PATCH] Colorscape Love (#3326) Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- API.Benchmark/ArchiveServiceBenchmark.cs | 2 +- API.Tests/Services/ArchiveServiceTests.cs | 6 +- API.Tests/Services/BookServiceTests.cs | 2 +- API.Tests/Services/ReadingListServiceTests.cs | 3 + API.Tests/Services/ScannerServiceTests.cs | 40 ++ API.Tests/Services/SeriesServiceTests.cs | 3 +- API/API.csproj | 1 + API/Controllers/ImageController.cs | 9 +- API/Controllers/PersonController.cs | 44 ++- API/Controllers/ReadingListController.cs | 2 +- API/DTOs/CoverDb/CoverDbAuthor.cs | 16 + API/DTOs/CoverDb/CoverDbPeople.cs | 10 + API/DTOs/CoverDb/CoverDbPersonIds.cs | 20 + API/DTOs/Person/UpdatePersonDto.cs | 2 + API/DTOs/ReadingLists/ReadingListItemDto.cs | 2 + .../MigrateDuplicateDarkTheme.cs | 67 ++++ API/Data/Repositories/CoverDbRepository.cs | 85 ++++ API/Data/Repositories/PersonRepository.cs | 6 + .../Repositories/ReadingListRepository.cs | 6 +- .../ApplicationServiceExtensions.cs | 1 + API/Extensions/DoubleExtensions.cs | 26 ++ API/Extensions/StringExtensions.cs | 20 +- API/I18N/en.json | 5 +- API/Services/ImageService.cs | 224 +---------- API/Services/ReadingListService.cs | 15 + API/Services/SeriesService.cs | 12 +- API/Services/Tasks/Metadata/CoverDbService.cs | 372 ++++++++++++++++++ API/Startup.cs | 1 + .../src/app/_services/colorscape.service.ts | 128 +++++- UI/Web/src/app/_services/person.service.ts | 5 + UI/Web/src/app/_services/theme.service.ts | 7 +- .../edit-chapter-modal.component.ts | 2 +- .../manage-tasks-settings.component.html | 8 +- .../entity-title/entity-title.component.html | 91 ----- .../entity-title/entity-title.component.ts | 16 +- .../edit-person-modal.component.html | 26 +- .../edit-person-modal.component.ts | 18 +- .../person-detail/person-detail.component.ts | 92 +++-- .../volume-detail.component.html | 28 +- .../volume-detail/volume-detail.component.ts | 2 - UI/Web/src/assets/langs/en.json | 11 +- UI/Web/src/main.ts | 2 +- 42 files changed, 1009 insertions(+), 429 deletions(-) create mode 100644 API/DTOs/CoverDb/CoverDbAuthor.cs create mode 100644 API/DTOs/CoverDb/CoverDbPeople.cs create mode 100644 API/DTOs/CoverDb/CoverDbPersonIds.cs create mode 100644 API/Data/ManualMigrations/MigrateDuplicateDarkTheme.cs create mode 100644 API/Data/Repositories/CoverDbRepository.cs create mode 100644 API/Extensions/DoubleExtensions.cs create mode 100644 API/Services/Tasks/Metadata/CoverDbService.cs diff --git a/API.Benchmark/ArchiveServiceBenchmark.cs b/API.Benchmark/ArchiveServiceBenchmark.cs index 9ef8e237b..ccb44d517 100644 --- a/API.Benchmark/ArchiveServiceBenchmark.cs +++ b/API.Benchmark/ArchiveServiceBenchmark.cs @@ -32,7 +32,7 @@ public class ArchiveServiceBenchmark public ArchiveServiceBenchmark() { _directoryService = new DirectoryService(null, new FileSystem()); - _imageService = new ImageService(null, _directoryService, Substitute.For()); + _imageService = new ImageService(null, _directoryService); _archiveService = new ArchiveService(new NullLogger(), _directoryService, _imageService, Substitute.For()); } diff --git a/API.Tests/Services/ArchiveServiceTests.cs b/API.Tests/Services/ArchiveServiceTests.cs index 086d99863..260676843 100644 --- a/API.Tests/Services/ArchiveServiceTests.cs +++ b/API.Tests/Services/ArchiveServiceTests.cs @@ -29,7 +29,7 @@ public class ArchiveServiceTests { _testOutputHelper = testOutputHelper; _archiveService = new ArchiveService(_logger, _directoryService, - new ImageService(Substitute.For>(), _directoryService, Substitute.For()), + new ImageService(Substitute.For>(), _directoryService), Substitute.For()); } @@ -167,7 +167,7 @@ public class ArchiveServiceTests public void GetCoverImage_Default_Test(string inputFile, string expectedOutputFile) { var ds = Substitute.For(_directoryServiceLogger, new FileSystem()); - var imageService = new ImageService(Substitute.For>(), ds, Substitute.For()); + var imageService = new ImageService(Substitute.For>(), ds); var archiveService = Substitute.For(_logger, ds, imageService, Substitute.For()); var testDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages")); @@ -198,7 +198,7 @@ public class ArchiveServiceTests [InlineData("sorting.zip", "sorting.expected.png")] public void GetCoverImage_SharpCompress_Test(string inputFile, string expectedOutputFile) { - var imageService = new ImageService(Substitute.For>(), _directoryService, Substitute.For()); + var imageService = new ImageService(Substitute.For>(), _directoryService); var archiveService = Substitute.For(_logger, new DirectoryService(_directoryServiceLogger, new FileSystem()), imageService, Substitute.For()); diff --git a/API.Tests/Services/BookServiceTests.cs b/API.Tests/Services/BookServiceTests.cs index e4647524e..23716e2f7 100644 --- a/API.Tests/Services/BookServiceTests.cs +++ b/API.Tests/Services/BookServiceTests.cs @@ -17,7 +17,7 @@ public class BookServiceTests { var directoryService = new DirectoryService(Substitute.For>(), new FileSystem()); _bookService = new BookService(_logger, directoryService, - new ImageService(Substitute.For>(), directoryService, Substitute.For()) + new ImageService(Substitute.For>(), directoryService) , Substitute.For()); } diff --git a/API.Tests/Services/ReadingListServiceTests.cs b/API.Tests/Services/ReadingListServiceTests.cs index 96a3effa4..6c24dd894 100644 --- a/API.Tests/Services/ReadingListServiceTests.cs +++ b/API.Tests/Services/ReadingListServiceTests.cs @@ -713,6 +713,9 @@ public class ReadingListServiceTests Assert.Equal("Issue #1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, "1", "1", "The Title"))); Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, "1", chapterTitleName: "The Title"))); Assert.Equal("The Title", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, chapterTitleName: "The Title"))); + var dto = CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, chapterNumber: "The Special Title"); + dto.IsSpecial = true; + Assert.Equal("The Special Title", ReadingListService.FormatTitle(dto)); // Book Library & Archive Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Book, "1"))); diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 57b2d68f3..58b1309fa 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -341,6 +341,46 @@ public class ScannerServiceTests : AbstractDbTest Assert.Equal(4, series.Volumes.First().Chapters.Count); } + /// + /// This is the same as doing ScanFolder as the case where it can find the series is just ScanSeries + /// + [Fact] + public async Task ScanSeries_NewChapterInNestedFolder() + { + const string testcase = "Series with Localized - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("My Dress-Up Darling v01.cbz", new ComicInfo() + { + Series = "My Dress-Up Darling", + LocalizedSeries = "Sono Bisque Doll wa Koi wo Suru" + }); + + var library = await GenerateScannerData(testcase, infos); + + + var scanner = CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + var series = postLib.Series.First(); + Assert.Equal(3, series.Volumes.Count); + + // Bootstrap a new file in the nested "Sono Bisque Doll wa Koi wo Suru" directory and perform a series scan + var testDirectory = Path.Combine(_testDirectory, Path.GetFileNameWithoutExtension(testcase)); + await Scaffold(testDirectory, ["My Dress-Up Darling/Sono Bisque Doll wa Koi wo Suru ch 11.cbz"]); + + // Now that a new file exists in the subdirectory, scan again + await scanner.ScanSeries(series.Id); + Assert.Single(postLib.Series); + Assert.Equal(3, series.Volumes.Count); + Assert.Equal(2, series.Volumes.First(v => v.MinNumber.Is(Parser.LooseLeafVolumeNumber)).Chapters.Count); + } + #region Setup private async Task GenerateScannerData(string testcase, Dictionary comicInfos = null) diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index 147fe96db..385b63f51 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -2080,7 +2080,7 @@ public class SeriesServiceTests : AbstractDbTest public async Task GetEstimatedChapterCreationDate_NextChapter_ChaptersMonthApart() { await ResetDb(); - var now = DateTime.UtcNow; + var now = DateTime.Parse("2021-01-01"); // 10/31/2024 can trigger an edge case bug _context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) @@ -2103,6 +2103,7 @@ public class SeriesServiceTests : AbstractDbTest Assert.Equal(Parser.LooseLeafVolumeNumber, nextChapter.VolumeNumber); Assert.Equal(5, nextChapter.ChapterNumber); Assert.NotNull(nextChapter.ExpectedDate); + var expected = now.AddMonths(4); Assert.Equal(expected.Month, nextChapter.ExpectedDate.Value.Month); Assert.True(nextChapter.ExpectedDate.Value.Day >= expected.Day - 1 || nextChapter.ExpectedDate.Value.Day <= expected.Day + 1); diff --git a/API/API.csproj b/API/API.csproj index b0fdfddc8..73ab423e4 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -106,6 +106,7 @@ + diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index 011ae471f..87e0542d1 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -7,6 +7,7 @@ using API.Data; using API.Entities.Enums; using API.Extensions; using API.Services; +using API.Services.Tasks.Metadata; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MimeTypes; @@ -26,17 +27,19 @@ public class ImageController : BaseApiController private readonly IImageService _imageService; private readonly ILocalizationService _localizationService; private readonly IReadingListService _readingListService; + private readonly ICoverDbService _coverDbService; /// public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService, IImageService imageService, ILocalizationService localizationService, - IReadingListService readingListService) + IReadingListService readingListService, ICoverDbService coverDbService) { _unitOfWork = unitOfWork; _directoryService = directoryService; _imageService = imageService; _localizationService = localizationService; _readingListService = readingListService; + _coverDbService = coverDbService; } /// @@ -230,7 +233,7 @@ public class ImageController : BaseApiController try { domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.FaviconDirectory, - await _imageService.DownloadFaviconAsync(url, encodeFormat)); + await _coverDbService.DownloadFaviconAsync(url, encodeFormat)); } catch (Exception) { @@ -270,7 +273,7 @@ public class ImageController : BaseApiController try { domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.PublisherDirectory, - await _imageService.DownloadPublisherImageAsync(publisherName, encodeFormat)); + await _coverDbService.DownloadPublisherImageAsync(publisherName, encodeFormat)); } catch (Exception) { diff --git a/API/Controllers/PersonController.cs b/API/Controllers/PersonController.cs index e5b9a99cc..15648318c 100644 --- a/API/Controllers/PersonController.cs +++ b/API/Controllers/PersonController.cs @@ -6,6 +6,8 @@ using API.Entities.Enums; using API.Extensions; using API.Helpers; using API.Services; +using API.Services.Tasks.Metadata; +using API.SignalR; using AutoMapper; using Microsoft.AspNetCore.Mvc; using Nager.ArticleNumber; @@ -18,12 +20,19 @@ public class PersonController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly ILocalizationService _localizationService; private readonly IMapper _mapper; + private readonly ICoverDbService _coverDbService; + private readonly IImageService _imageService; + private readonly IEventHub _eventHub; - public PersonController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper) + public PersonController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper, + ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub) { _unitOfWork = unitOfWork; _localizationService = localizationService; _mapper = mapper; + _coverDbService = coverDbService; + _imageService = imageService; + _eventHub = eventHub; } @@ -65,8 +74,17 @@ public class PersonController : BaseApiController var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id); if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist")); - dto.Description ??= string.Empty; - person.Description = dto.Description; + if (string.IsNullOrEmpty(dto.Name)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-required")); + + + // Validate the name is unique + if (dto.Name != person.Name && !(await _unitOfWork.PersonRepository.IsNameUnique(dto.Name))) + { + return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-unique")); + } + + person.Name = dto.Name?.Trim(); + person.Description = dto.Description ?? string.Empty; person.CoverImageLocked = dto.CoverImageLocked; if (dto.MalId is > 0) @@ -96,6 +114,26 @@ public class PersonController : BaseApiController return Ok(_mapper.Map(person)); } + [HttpPost("fetch-cover")] + public async Task> DownloadCoverImage([FromQuery] int personId) + { + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var person = await _unitOfWork.PersonRepository.GetPersonById(personId); + if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist")); + + var personImage = await _coverDbService.DownloadPersonImageAsync(person, settings.EncodeMediaAs); + + if (string.IsNullOrEmpty(personImage)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-image-doesnt-exist")); + person.CoverImage = personImage; + _imageService.UpdateColorScape(person); + _unitOfWork.PersonRepository.Update(person); + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(person.Id, "person"), false); + + + return Ok(personImage); + } + /// /// Returns the top 20 series that the "person" is known for. This will use Average Rating when applicable (Kavita+ field), else it's a random sort /// diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 6c23f5652..5e84e9f64 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -162,7 +162,7 @@ public class ReadingListController : BaseApiController return Ok(await _localizationService.Translate(User.GetUserId(), "reading-list-updated")); } - return BadRequest("Couldn't delete item(s)"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-item-delete")); } /// diff --git a/API/DTOs/CoverDb/CoverDbAuthor.cs b/API/DTOs/CoverDb/CoverDbAuthor.cs new file mode 100644 index 000000000..2f023398a --- /dev/null +++ b/API/DTOs/CoverDb/CoverDbAuthor.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using YamlDotNet.Serialization; + +namespace API.DTOs.CoverDb; + +public class CoverDbAuthor +{ + [YamlMember(Alias = "name", ApplyNamingConventions = false)] + public string Name { get; set; } + [YamlMember(Alias = "aliases", ApplyNamingConventions = false)] + public List Aliases { get; set; } = new List(); + [YamlMember(Alias = "ids", ApplyNamingConventions = false)] + public CoverDbPersonIds Ids { get; set; } + [YamlMember(Alias = "image_path", ApplyNamingConventions = false)] + public string ImagePath { get; set; } +} diff --git a/API/DTOs/CoverDb/CoverDbPeople.cs b/API/DTOs/CoverDb/CoverDbPeople.cs new file mode 100644 index 000000000..c0f5e327e --- /dev/null +++ b/API/DTOs/CoverDb/CoverDbPeople.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using YamlDotNet.Serialization; + +namespace API.DTOs.CoverDb; + +public class CoverDbPeople +{ + [YamlMember(Alias = "people", ApplyNamingConventions = false)] + public List People { get; set; } = new List(); +} diff --git a/API/DTOs/CoverDb/CoverDbPersonIds.cs b/API/DTOs/CoverDb/CoverDbPersonIds.cs new file mode 100644 index 000000000..9c59415e6 --- /dev/null +++ b/API/DTOs/CoverDb/CoverDbPersonIds.cs @@ -0,0 +1,20 @@ +using YamlDotNet.Serialization; + +namespace API.DTOs.CoverDb; +#nullable enable + +public class CoverDbPersonIds +{ + [YamlMember(Alias = "hardcover_id", ApplyNamingConventions = false)] + public string? HardcoverId { get; set; } = null; + [YamlMember(Alias = "amazon_id", ApplyNamingConventions = false)] + public string? AmazonId { get; set; } = null; + [YamlMember(Alias = "metron_id", ApplyNamingConventions = false)] + public string? MetronId { get; set; } = null; + [YamlMember(Alias = "comicvine_id", ApplyNamingConventions = false)] + public string? ComicVineId { get; set; } = null; + [YamlMember(Alias = "anilist_id", ApplyNamingConventions = false)] + public string? AnilistId { get; set; } = null; + [YamlMember(Alias = "mal_id", ApplyNamingConventions = false)] + public string? MALId { get; set; } = null; +} diff --git a/API/DTOs/Person/UpdatePersonDto.cs b/API/DTOs/Person/UpdatePersonDto.cs index fe5763257..78eb54aaf 100644 --- a/API/DTOs/Person/UpdatePersonDto.cs +++ b/API/DTOs/Person/UpdatePersonDto.cs @@ -8,6 +8,8 @@ public class UpdatePersonDto public int Id { get; init; } [Required] public bool CoverImageLocked { get; set; } + [Required] + public string Name {get; set;} public string? Description { get; set; } public int? AniListId { get; set; } diff --git a/API/DTOs/ReadingLists/ReadingListItemDto.cs b/API/DTOs/ReadingLists/ReadingListItemDto.cs index 893bf552b..f1238d333 100644 --- a/API/DTOs/ReadingLists/ReadingListItemDto.cs +++ b/API/DTOs/ReadingLists/ReadingListItemDto.cs @@ -43,4 +43,6 @@ public class ReadingListItemDto /// The chapter summary /// public string? Summary { get; set; } + + public bool IsSpecial { get; set; } } diff --git a/API/Data/ManualMigrations/MigrateDuplicateDarkTheme.cs b/API/Data/ManualMigrations/MigrateDuplicateDarkTheme.cs new file mode 100644 index 000000000..2e31c3392 --- /dev/null +++ b/API/Data/ManualMigrations/MigrateDuplicateDarkTheme.cs @@ -0,0 +1,67 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Services.Tasks.Scanner.Parser; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.0 ensured that MangaFile Path is normalized. This will normalize existing data to avoid churn. +/// +public static class MigrateDuplicateDarkTheme +{ + public static async Task Migrate(DataContext dataContext, ILogger logger) + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateDuplicateDarkTheme")) + { + return; + } + + logger.LogCritical( + "Running MigrateDuplicateDarkTheme migration - Please be patient, this may take some time. This is not an error"); + + var darkThemes = await dataContext.SiteTheme.Where(t => t.Name == "Dark").ToListAsync(); + + if (darkThemes.Count > 1) + { + var correctDarkTheme = darkThemes.First(d => !string.IsNullOrEmpty(d.Description)); + + // Get users + var users = await dataContext.AppUser + .Include(u => u.UserPreferences) + .ThenInclude(p => p.Theme) + .Where(u => u.UserPreferences.Theme.Name == "Dark") + .ToListAsync(); + + // Find any users that have a duplicate Dark theme as default and switch to the correct one + foreach (var user in users) + { + if (string.IsNullOrEmpty(user.UserPreferences.Theme.Description)) + { + user.UserPreferences.Theme = correctDarkTheme; + } + } + await dataContext.SaveChangesAsync(); + + // Now remove the bad themes + dataContext.SiteTheme.RemoveRange(darkThemes.Where(d => string.IsNullOrEmpty(d.Description))); + + await dataContext.SaveChangesAsync(); + } + + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateDuplicateDarkTheme", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await dataContext.SaveChangesAsync(); + + logger.LogCritical( + "Running MigrateDuplicateDarkTheme migration - Completed. This is not an error"); + } +} diff --git a/API/Data/Repositories/CoverDbRepository.cs b/API/Data/Repositories/CoverDbRepository.cs new file mode 100644 index 000000000..3563f9357 --- /dev/null +++ b/API/Data/Repositories/CoverDbRepository.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using API.DTOs.CoverDb; +using API.Entities; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace API.Data.Repositories; +#nullable enable + +/// +/// This is a manual repository, not a DB repo +/// +public class CoverDbRepository +{ + private readonly List _authors; + + public CoverDbRepository(string filePath) + { + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + // Read and deserialize YAML file + var yamlContent = File.ReadAllText(filePath); + var peopleData = deserializer.Deserialize(yamlContent); + _authors = peopleData.People; + } + + public CoverDbAuthor? FindAuthorByNameOrAlias(string name) + { + return _authors.Find(author => + author.Name.Equals(name, StringComparison.OrdinalIgnoreCase) || + author.Aliases.Contains(name, StringComparer.OrdinalIgnoreCase)); + } + + public CoverDbAuthor? FindBestAuthorMatch(Person person) + { + var aniListId = person.AniListId > 0 ? $"{person.AniListId}" : string.Empty; + var highestScore = 0; + CoverDbAuthor? bestMatch = null; + + foreach (var author in _authors) + { + var score = 0; + + // Check metadata IDs and add points if they match + if (!string.IsNullOrEmpty(author.Ids.AmazonId) && author.Ids.AmazonId == person.Asin) + { + score += 10; + } + if (!string.IsNullOrEmpty(author.Ids.AnilistId) && author.Ids.AnilistId == aniListId) + { + score += 10; + } + if (!string.IsNullOrEmpty(author.Ids.HardcoverId) && author.Ids.HardcoverId == person.HardcoverId) + { + score += 10; + } + + // Check for exact name match + if (author.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase)) + { + score += 7; + } + + // Check for alias match + if (author.Aliases.Contains(person.Name, StringComparer.OrdinalIgnoreCase)) + { + score += 5; + } + + // Update the best match if current score is higher + if (score <= highestScore) continue; + + highestScore = score; + bestMatch = author; + } + + return bestMatch; + } + +} diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index c6c437103..4c5093377 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -38,6 +38,7 @@ public interface IPersonRepository Task GetPersonById(int personId); Task GetPersonDtoByName(string name, int userId); Task GetPersonByName(string name); + Task IsNameUnique(string name); Task> GetSeriesKnownFor(int personId); Task> GetChaptersForPersonByRole(int personId, int userId, PersonRole role); @@ -211,6 +212,11 @@ public class PersonRepository : IPersonRepository return await _context.Person.FirstOrDefaultAsync(p => p.NormalizedName == name.ToNormalized()); } + public async Task IsNameUnique(string name) + { + return !(await _context.Person.AnyAsync(p => p.Name == name)); + } + public async Task> GetSeriesKnownFor(int personId) { return await _context.Person diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index ef6cf01fc..b2cc5d007 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -248,6 +248,7 @@ public class ReadingListRepository : IReadingListRepository ChapterTitleName = chapter.TitleName, FileSize = chapter.Files.Sum(f => f.Bytes), chapter.Summary, + chapter.IsSpecial }) .Join(_context.Volume, s => s.ReadingListItem.VolumeId, volume => volume.Id, (data, volume) => new @@ -259,6 +260,7 @@ public class ReadingListRepository : IReadingListRepository data.ChapterTitleName, data.FileSize, data.Summary, + data.IsSpecial, VolumeId = volume.Id, VolumeNumber = volume.Name, }) @@ -277,6 +279,7 @@ public class ReadingListRepository : IReadingListRepository data.ChapterTitleName, data.FileSize, data.Summary, + data.IsSpecial, LibraryName = _context.Library.Where(l => l.Id == s.LibraryId).Select(l => l.Name).Single(), LibraryType = _context.Library.Where(l => l.Id == s.LibraryId).Select(l => l.Type).Single() }) @@ -299,7 +302,8 @@ public class ReadingListRepository : IReadingListRepository ChapterTitleName = data.ChapterTitleName, LibraryName = data.LibraryName, FileSize = data.FileSize, - Summary = data.Summary + Summary = data.Summary, + IsSpecial = data.IsSpecial }) .Where(o => userLibraries.Contains(o.LibraryId)) .OrderBy(rli => rli.Order) diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 4b5c73af7..b5c76b443 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -68,6 +68,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); diff --git a/API/Extensions/DoubleExtensions.cs b/API/Extensions/DoubleExtensions.cs new file mode 100644 index 000000000..3deb37ffb --- /dev/null +++ b/API/Extensions/DoubleExtensions.cs @@ -0,0 +1,26 @@ +using System; + +namespace API.Extensions; + +public static class DoubleExtensions +{ + private const float Tolerance = 0.001f; + + /// + /// Used to compare 2 floats together + /// + /// + /// + /// + public static bool Is(this double a, double? b) + { + if (!b.HasValue) return false; + return Math.Abs((float) (a - b)) < Tolerance; + } + + public static bool IsNot(this double a, double? b) + { + if (!b.HasValue) return false; + return Math.Abs((float) (a - b)) > Tolerance; + } +} diff --git a/API/Extensions/StringExtensions.cs b/API/Extensions/StringExtensions.cs index 802c4bca4..138209e0d 100644 --- a/API/Extensions/StringExtensions.cs +++ b/API/Extensions/StringExtensions.cs @@ -1,4 +1,5 @@ -using System.Globalization; +using System; +using System.Globalization; using System.Text.RegularExpressions; namespace API.Extensions; @@ -10,6 +11,23 @@ public static class StringExtensions RegexOptions.ExplicitCapture | RegexOptions.Compiled, Services.Tasks.Scanner.Parser.Parser.RegexTimeout); + public static string Sanitize(this string input) + { + if (string.IsNullOrEmpty(input)) + return string.Empty; + + // Remove all newline and control characters + var sanitized = input + .Replace(Environment.NewLine, "") + .Replace("\n", "") + .Replace("\r", ""); + + // Optionally remove other potentially unwanted characters + sanitized = Regex.Replace(sanitized, @"[^\u0020-\u007E]", string.Empty); // Removes non-printable ASCII + + return sanitized.Trim(); // Trim any leading/trailing whitespace + } + public static string SentenceCase(this string value) { return SentenceCaseRegex.Replace(value.ToLower(), s => s.Value.ToUpper()); diff --git a/API/I18N/en.json b/API/I18N/en.json index 1e8b5aa1f..8781a8603 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -53,6 +53,9 @@ "error-import-stack": "There was an issue importing MAL stack", "person-doesnt-exist": "Person does not exist", + "person-name-required": "Person name is required and must not be null", + "person-name-unique": "Person name must be unique", + "person-image-doesnt-exist": "Person does not exist in CoversDB", "device-doesnt-exist": "Device does not exist", "generic-device-create": "There was an error when creating the device", @@ -61,7 +64,7 @@ "greater-0": "{0} must be greater than 0", "send-to-kavita-email": "Send to device cannot be used without Email setup", "send-to-unallowed":"You cannot send to a device that isn't yours", - "send-to-size-limit": "The file(s) you are trying to send are too large for your emailer", + "send-to-size-limit": "The file(s) you are trying to send are too large for your email provider", "send-to-device-status": "Transferring files to your device", "generic-send-to": "There was an error sending the file(s) to the device", "series-doesnt-exist": "Series does not exist", diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 3236acdf9..8d2600f71 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -7,6 +7,7 @@ using System.Numerics; using System.Threading.Tasks; using API.Constants; using API.DTOs; +using API.Entities; using API.Entities.Enums; using API.Entities.Interfaces; using API.Extensions; @@ -66,17 +67,15 @@ public interface IImageService /// File of written encoded image Task ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat); Task IsImage(string filePath); - Task DownloadFaviconAsync(string url, EncodeFormat encodeFormat); - Task DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat); void UpdateColorScape(IHasCoverImage entity); } public class ImageService : IImageService { - public const string Name = "BookmarkService"; + public const string Name = "ImageService"; private readonly ILogger _logger; private readonly IDirectoryService _directoryService; - private readonly IEasyCachingProviderFactory _cacheFactory; + public const string ChapterCoverImageRegex = @"v\d+_c\d+"; public const string SeriesCoverImageRegex = @"series\d+"; public const string CollectionTagCoverImageRegex = @"tag\d+"; @@ -100,26 +99,10 @@ public class ImageService : IImageService public const int LibraryThumbnailWidth = 32; - private static readonly string[] ValidIconRelations = { - "icon", - "apple-touch-icon", - "apple-touch-icon-precomposed", - "apple-touch-icon icon-precomposed" // ComicVine has it combined - }; - - /// - /// A mapping of urls that need to get the icon from another url, due to strangeness (like app.plex.tv loading a black icon) - /// - private static readonly IDictionary FaviconUrlMapper = new Dictionary - { - ["https://app.plex.tv"] = "https://plex.tv" - }; - - public ImageService(ILogger logger, IDirectoryService directoryService, IEasyCachingProviderFactory cacheFactory) + public ImageService(ILogger logger, IDirectoryService directoryService) { _logger = logger; _directoryService = directoryService; - _cacheFactory = cacheFactory; } public void ExtractImages(string? fileFilePath, string targetDirectory, int fileCount = 1) @@ -335,151 +318,8 @@ public class ImageService : IImageService return false; } - public async Task DownloadFaviconAsync(string url, EncodeFormat encodeFormat) - { - // Parse the URL to get the domain (including subdomain) - var uri = new Uri(url); - var domain = uri.Host.Replace(Environment.NewLine, string.Empty); - var baseUrl = uri.Scheme + "://" + uri.Host; - var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Favicon); - var res = await provider.GetAsync(baseUrl); - if (res.HasValue) - { - _logger.LogInformation("Kavita has already tried to fetch from {BaseUrl} and failed. Skipping duplicate check", baseUrl); - throw new KavitaException($"Kavita has already tried to fetch from {baseUrl} and failed. Skipping duplicate check"); - } - - await provider.SetAsync(baseUrl, string.Empty, TimeSpan.FromDays(10)); - if (FaviconUrlMapper.TryGetValue(baseUrl, out var value)) - { - url = value; - } - - var correctSizeLink = string.Empty; - - try - { - var htmlContent = url.GetStringAsync().Result; - var htmlDocument = new HtmlDocument(); - htmlDocument.LoadHtml(htmlContent); - var pngLinks = htmlDocument.DocumentNode.Descendants("link") - .Where(link => ValidIconRelations.Contains(link.GetAttributeValue("rel", string.Empty))) - .Select(link => link.GetAttributeValue("href", string.Empty)) - .Where(href => href.Split("?")[0].EndsWith(".png", StringComparison.InvariantCultureIgnoreCase)) - .ToList(); - - correctSizeLink = (pngLinks?.Find(pngLink => pngLink.Contains("32")) ?? pngLinks?.FirstOrDefault()); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error downloading favicon.png for {Domain}, will try fallback methods", domain); - } - - try - { - if (string.IsNullOrEmpty(correctSizeLink)) - { - correctSizeLink = await FallbackToKavitaReaderFavicon(baseUrl); - } - if (string.IsNullOrEmpty(correctSizeLink)) - { - throw new KavitaException($"Could not grab favicon from {baseUrl}"); - } - - var finalUrl = correctSizeLink; - - // If starts with //, it's coming usually from an offsite cdn - if (correctSizeLink.StartsWith("//")) - { - finalUrl = "https:" + correctSizeLink; - } - else if (!correctSizeLink.StartsWith(uri.Scheme)) - { - finalUrl = Url.Combine(baseUrl, correctSizeLink); - } - - _logger.LogTrace("Fetching favicon from {Url}", finalUrl); - // Download the favicon.ico file using Flurl - var faviconStream = await finalUrl - .AllowHttpStatus("2xx,304") - .GetStreamAsync(); - - // Create the destination file path - using var image = Image.PngloadStream(faviconStream); - var filename = ImageService.GetWebLinkFormat(baseUrl, encodeFormat); - switch (encodeFormat) - { - case EncodeFormat.PNG: - image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename)); - break; - case EncodeFormat.WEBP: - image.Webpsave(Path.Combine(_directoryService.FaviconDirectory, filename)); - break; - case EncodeFormat.AVIF: - image.Heifsave(Path.Combine(_directoryService.FaviconDirectory, filename)); - break; - default: - throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); - } - - - _logger.LogDebug("Favicon for {Domain} downloaded and saved successfully", domain); - return filename; - } catch (Exception ex) - { - _logger.LogError(ex, "Error downloading favicon for {Domain}", domain); - throw; - } - } - - public async Task DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat) - { - try - { - var publisherLink = await FallbackToKavitaReaderPublisher(publisherName); - if (string.IsNullOrEmpty(publisherLink)) - { - throw new KavitaException($"Could not grab publisher image for {publisherName}"); - } - - var finalUrl = publisherLink; - - _logger.LogTrace("Fetching publisher image from {Url}", finalUrl); - // Download the favicon.ico file using Flurl - var publisherStream = await finalUrl - .AllowHttpStatus("2xx,304") - .GetStreamAsync(); - - // Create the destination file path - using var image = Image.PngloadStream(publisherStream); - var filename = GetPublisherFormat(publisherName, encodeFormat); - switch (encodeFormat) - { - case EncodeFormat.PNG: - image.Pngsave(Path.Combine(_directoryService.PublisherDirectory, filename)); - break; - case EncodeFormat.WEBP: - image.Webpsave(Path.Combine(_directoryService.PublisherDirectory, filename)); - break; - case EncodeFormat.AVIF: - image.Heifsave(Path.Combine(_directoryService.PublisherDirectory, filename)); - break; - default: - throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); - } - - - _logger.LogDebug("Publisher image for {PublisherName} downloaded and saved successfully", publisherName); - return filename; - } catch (Exception ex) - { - _logger.LogError(ex, "Error downloading image for {PublisherName}", publisherName); - throw; - } - } - private static (Vector3?, Vector3?) GetPrimarySecondaryColors(string imagePath) { using var image = Image.NewFromFile(imagePath); @@ -740,63 +580,7 @@ public class ImageService : IImageService }; } - private static async Task FallbackToKavitaReaderFavicon(string baseUrl) - { - var correctSizeLink = string.Empty; - var allOverrides = await "https://www.kavitareader.com/assets/favicons/urls.txt".GetStringAsync(); - if (!string.IsNullOrEmpty(allOverrides)) - { - var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty); - var externalFile = allOverrides - .Split("\n") - .FirstOrDefault(url => - cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) || - cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty) - )); - if (string.IsNullOrEmpty(externalFile)) - { - throw new KavitaException($"Could not grab favicon from {baseUrl}"); - } - - correctSizeLink = "https://www.kavitareader.com/assets/favicons/" + externalFile; - } - - return correctSizeLink; - } - - private static async Task FallbackToKavitaReaderPublisher(string publisherName) - { - var externalLink = string.Empty; - var allOverrides = await "https://www.kavitareader.com/assets/publishers/publishers.txt".GetStringAsync(); - if (!string.IsNullOrEmpty(allOverrides)) - { - var externalFile = allOverrides - .Split("\n") - .Select(publisherLine => - { - var tokens = publisherLine.Split("|"); - if (tokens.Length != 2) return null; - var aliases = tokens[0]; - // Multiple publisher aliases are separated by # - if (aliases.Split("#").Any(name => name.ToLowerInvariant().Trim().Equals(publisherName.ToLowerInvariant().Trim()))) - { - return tokens[1]; - } - return null; - }) - .FirstOrDefault(url => !string.IsNullOrEmpty(url)); - - if (string.IsNullOrEmpty(externalFile)) - { - throw new KavitaException($"Could not grab publisher image for {publisherName}"); - } - - externalLink = "https://www.kavitareader.com/assets/publishers/" + externalFile; - } - - return externalLink; - } /// public string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = ThumbnailWidth) diff --git a/API/Services/ReadingListService.cs b/API/Services/ReadingListService.cs index ae3c26a89..1394b131a 100644 --- a/API/Services/ReadingListService.cs +++ b/API/Services/ReadingListService.cs @@ -107,15 +107,30 @@ public class ReadingListService : IReadingListService if (title != string.Empty) return title; + // item.ChapterNumber is Range if (item.ChapterNumber == Parser.DefaultChapter && !string.IsNullOrEmpty(item.ChapterTitleName)) { title = item.ChapterTitleName; } + else if (item.IsSpecial && + (!string.IsNullOrEmpty(item.ChapterTitleName) || !string.IsNullOrEmpty(chapterNum))) + { + if (!string.IsNullOrEmpty(item.ChapterTitleName)) + { + title = item.ChapterTitleName; + } + else + { + title = chapterNum; + } + + } else { title = ReaderService.FormatChapterName(item.LibraryType, true, true) + chapterNum; } + return title; } diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 9140d26e6..c40252188 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -910,9 +910,14 @@ public class SeriesService : ISeriesService } // Calculate the forecast for when the next chapter is expected - var nextChapterExpected = chapters.Any() - ? chapters.Max(c => c.CreatedUtc) + TimeSpan.FromDays(forecastedTimeDifference) - : (DateTime?)null; + // var nextChapterExpected = chapters.Count > 0 + // ? chapters.Max(c => c.CreatedUtc) + TimeSpan.FromDays(forecastedTimeDifference) + // : (DateTime?)null; + var lastChapterDate = chapters.Max(c => c.CreatedUtc); + var estimatedDate = lastChapterDate.AddDays(forecastedTimeDifference); + var nextChapterExpected = estimatedDate.Day > DateTime.DaysInMonth(estimatedDate.Year, estimatedDate.Month) + ? new DateTime(estimatedDate.Year, estimatedDate.Month, DateTime.DaysInMonth(estimatedDate.Year, estimatedDate.Month)) + : estimatedDate; // For number and volume number, we need the highest chapter, not the latest created var lastChapter = chapters.MaxBy(c => c.MaxNumber)!; @@ -936,6 +941,7 @@ public class SeriesService : ISeriesService { LibraryType.Manga => await _localizationService.Translate(userId, "chapter-num", result.ChapterNumber), LibraryType.Comic => await _localizationService.Translate(userId, "issue-num", "#", result.ChapterNumber), + LibraryType.ComicVine => await _localizationService.Translate(userId, "issue-num", "#", result.ChapterNumber), LibraryType.Book => await _localizationService.Translate(userId, "book-num", result.ChapterNumber), LibraryType.LightNovel => await _localizationService.Translate(userId, "book-num", result.ChapterNumber), _ => await _localizationService.Translate(userId, "chapter-num", result.ChapterNumber) diff --git a/API/Services/Tasks/Metadata/CoverDbService.cs b/API/Services/Tasks/Metadata/CoverDbService.cs new file mode 100644 index 000000000..da83eebf6 --- /dev/null +++ b/API/Services/Tasks/Metadata/CoverDbService.cs @@ -0,0 +1,372 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Constants; +using API.Data.Repositories; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using EasyCaching.Core; +using Flurl; +using Flurl.Http; +using HtmlAgilityPack; +using Kavita.Common; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NetVips; + +namespace API.Services.Tasks.Metadata; + +public interface ICoverDbService +{ + Task DownloadFaviconAsync(string url, EncodeFormat encodeFormat); + Task DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat); + Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat); +} + + +public class CoverDbService : ICoverDbService +{ + private readonly ILogger _logger; + private readonly IDirectoryService _directoryService; + private readonly IEasyCachingProviderFactory _cacheFactory; + private readonly IHostEnvironment _env; + + private const string NewHost = "https://www.kavitareader.com/CoversDB/"; + + private static readonly string[] ValidIconRelations = { + "icon", + "apple-touch-icon", + "apple-touch-icon-precomposed", + "apple-touch-icon icon-precomposed" // ComicVine has it combined + }; + + /// + /// A mapping of urls that need to get the icon from another url, due to strangeness (like app.plex.tv loading a black icon) + /// + private static readonly Dictionary FaviconUrlMapper = new() + { + ["https://app.plex.tv"] = "https://plex.tv" + }; + + public CoverDbService(ILogger logger, IDirectoryService directoryService, + IEasyCachingProviderFactory cacheFactory, IHostEnvironment env) + { + _logger = logger; + _directoryService = directoryService; + _cacheFactory = cacheFactory; + _env = env; + } + + public async Task DownloadFaviconAsync(string url, EncodeFormat encodeFormat) + { + // Parse the URL to get the domain (including subdomain) + var uri = new Uri(url); + var domain = uri.Host.Replace(Environment.NewLine, string.Empty); + var baseUrl = uri.Scheme + "://" + uri.Host; + + + var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Favicon); + var res = await provider.GetAsync(baseUrl); + if (res.HasValue) + { + var sanitizedBaseUrl = baseUrl.Sanitize(); + _logger.LogInformation("Kavita has already tried to fetch from {BaseUrl} and failed. Skipping duplicate check", sanitizedBaseUrl); + throw new KavitaException($"Kavita has already tried to fetch from {sanitizedBaseUrl} and failed. Skipping duplicate check"); + } + + await provider.SetAsync(baseUrl, string.Empty, TimeSpan.FromDays(10)); + if (FaviconUrlMapper.TryGetValue(baseUrl, out var value)) + { + url = value; + } + + var correctSizeLink = string.Empty; + + try + { + var htmlContent = url.GetStringAsync().Result; + var htmlDocument = new HtmlDocument(); + htmlDocument.LoadHtml(htmlContent); + + var pngLinks = htmlDocument.DocumentNode.Descendants("link") + .Where(link => ValidIconRelations.Contains(link.GetAttributeValue("rel", string.Empty))) + .Select(link => link.GetAttributeValue("href", string.Empty)) + .Where(href => href.Split("?")[0].EndsWith(".png", StringComparison.InvariantCultureIgnoreCase)) + .ToList(); + + correctSizeLink = (pngLinks?.Find(pngLink => pngLink.Contains("32")) ?? pngLinks?.FirstOrDefault()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error downloading favicon.png for {Domain}, will try fallback methods", domain); + } + + try + { + if (string.IsNullOrEmpty(correctSizeLink)) + { + correctSizeLink = await FallbackToKavitaReaderFavicon(baseUrl); + } + if (string.IsNullOrEmpty(correctSizeLink)) + { + throw new KavitaException($"Could not grab favicon from {baseUrl}"); + } + + var finalUrl = correctSizeLink; + + // If starts with //, it's coming usually from an offsite cdn + if (correctSizeLink.StartsWith("//")) + { + finalUrl = "https:" + correctSizeLink; + } + else if (!correctSizeLink.StartsWith(uri.Scheme)) + { + finalUrl = Url.Combine(baseUrl, correctSizeLink); + } + + _logger.LogTrace("Fetching favicon from {Url}", finalUrl); + // Download the favicon.ico file using Flurl + var faviconStream = await finalUrl + .AllowHttpStatus("2xx,304") + .GetStreamAsync(); + + // Create the destination file path + using var image = Image.PngloadStream(faviconStream); + var filename = ImageService.GetWebLinkFormat(baseUrl, encodeFormat); + switch (encodeFormat) + { + case EncodeFormat.PNG: + image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename)); + break; + case EncodeFormat.WEBP: + image.Webpsave(Path.Combine(_directoryService.FaviconDirectory, filename)); + break; + case EncodeFormat.AVIF: + image.Heifsave(Path.Combine(_directoryService.FaviconDirectory, filename)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); + } + + + _logger.LogDebug("Favicon for {Domain} downloaded and saved successfully", domain); + return filename; + } catch (Exception ex) + { + _logger.LogError(ex, "Error downloading favicon for {Domain}", domain); + throw; + } + } + + public async Task DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat) + { + try + { + var publisherLink = await FallbackToKavitaReaderPublisher(publisherName); + if (string.IsNullOrEmpty(publisherLink)) + { + throw new KavitaException($"Could not grab publisher image for {publisherName}"); + } + + _logger.LogTrace("Fetching publisher image from {Url}", publisherLink.Sanitize()); + // Download the publisher file using Flurl + var publisherStream = await publisherLink + .AllowHttpStatus("2xx,304") + .GetStreamAsync(); + + // Create the destination file path + using var image = Image.NewFromStream(publisherStream); + var filename = ImageService.GetPublisherFormat(publisherName, encodeFormat); + switch (encodeFormat) + { + case EncodeFormat.PNG: + image.Pngsave(Path.Combine(_directoryService.PublisherDirectory, filename)); + break; + case EncodeFormat.WEBP: + image.Webpsave(Path.Combine(_directoryService.PublisherDirectory, filename)); + break; + case EncodeFormat.AVIF: + image.Heifsave(Path.Combine(_directoryService.PublisherDirectory, filename)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); + } + + + _logger.LogDebug("Publisher image for {PublisherName} downloaded and saved successfully", publisherName.Sanitize()); + return filename; + } catch (Exception ex) + { + _logger.LogError(ex, "Error downloading image for {PublisherName}", publisherName.Sanitize()); + throw; + } + } + + /// + /// Attempts to download the Person image from CoverDB while matching against metadata within the Person + /// + /// + /// + /// Person image (in correct directory) or null if not found/error + public async Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat) + { + try + { + var personImageLink = await GetCoverPersonImagePath(person); + if (string.IsNullOrEmpty(personImageLink)) + { + throw new KavitaException($"Could not grab person image for {person.Name}"); + } + + // Create the destination file path + var filename = ImageService.GetPersonFormat(person.Id) + encodeFormat.GetExtension(); + var targetFile = Path.Combine(_directoryService.CoverImageDirectory, filename); + + // Ensure if file exists, we delete to overwrite + + + _logger.LogTrace("Fetching publisher image from {Url}", personImageLink.Sanitize()); + // Download the publisher file using Flurl + var personStream = await personImageLink + .AllowHttpStatus("2xx,304") + .GetStreamAsync(); + + using var image = Image.NewFromStream(personStream); + switch (encodeFormat) + { + case EncodeFormat.PNG: + image.Pngsave(targetFile); + break; + case EncodeFormat.WEBP: + image.Webpsave(targetFile); + break; + case EncodeFormat.AVIF: + image.Heifsave(targetFile); + break; + default: + throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); + } + + _logger.LogDebug("Person image for {PersonName} downloaded and saved successfully", person.Name); + + return filename; + } catch (Exception ex) + { + _logger.LogError(ex, "Error downloading image for {PersonName}", person.Name); + } + + return null; + } + + private async Task GetCoverPersonImagePath(Person person) + { + var tempFile = Path.Join(_directoryService.TempDirectory, "people.yml"); + + // Check if the file already exists and skip download in Development environment + if (File.Exists(tempFile)) + { + if (_env.IsDevelopment()) + { + _logger.LogInformation("Using existing people.yml file in Development environment"); + } + else + { + // Remove file if not in Development and file is older than 7 days + if (File.GetLastWriteTime(tempFile) < DateTime.Now.AddDays(-7)) + { + File.Delete(tempFile); + } + } + } + + // Download the file if it doesn't exist or was deleted due to age + if (!File.Exists(tempFile)) + { + var masterPeopleFile = await $"{NewHost}people/people.yml" + .DownloadFileAsync(_directoryService.TempDirectory); + + if (!File.Exists(tempFile) || string.IsNullOrEmpty(masterPeopleFile)) + { + _logger.LogError("Could not download people.yml from Github"); + return null; + } + } + + + var coverDbRepository = new CoverDbRepository(tempFile); + + var coverAuthor = coverDbRepository.FindBestAuthorMatch(person); + if (coverAuthor == null || string.IsNullOrEmpty(coverAuthor.ImagePath)) + { + throw new KavitaException($"Could not grab person image for {person.Name}"); + } + + return $"{NewHost}{coverAuthor.ImagePath}"; + } + + private static async Task FallbackToKavitaReaderFavicon(string baseUrl) + { + var correctSizeLink = string.Empty; + // TODO: Pull this down and store it in temp/ to save on requests + var allOverrides = await $"{NewHost}favicons/urls.txt" + .GetStringAsync(); + + if (!string.IsNullOrEmpty(allOverrides)) + { + var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty); + var externalFile = allOverrides + .Split("\n") + .FirstOrDefault(url => + cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) || + cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty) + )); + + if (string.IsNullOrEmpty(externalFile)) + { + throw new KavitaException($"Could not grab favicon from {baseUrl.Sanitize()}"); + } + + correctSizeLink = $"{NewHost}favicons/" + externalFile; + } + + return correctSizeLink; + } + + private static async Task FallbackToKavitaReaderPublisher(string publisherName) + { + var externalLink = string.Empty; + // TODO: Pull this down and store it in temp/ to save on requests + var allOverrides = await $"{NewHost}publishers/publishers.txt".GetStringAsync(); + + if (!string.IsNullOrEmpty(allOverrides)) + { + var externalFile = allOverrides + .Split("\n") + .Select(publisherLine => + { + var tokens = publisherLine.Split("|"); + if (tokens.Length != 2) return null; + var aliases = tokens[0]; + // Multiple publisher aliases are separated by # + if (aliases.Split("#").Any(name => name.ToLowerInvariant().Trim().Equals(publisherName.ToLowerInvariant().Trim()))) + { + return tokens[1]; + } + return null; + }) + .FirstOrDefault(url => !string.IsNullOrEmpty(url)); + + if (string.IsNullOrEmpty(externalFile)) + { + throw new KavitaException($"Could not grab publisher image for {publisherName}"); + } + + externalLink = $"{NewHost}publishers/" + externalFile; + } + + return externalLink; + } +} diff --git a/API/Startup.cs b/API/Startup.cs index 8567ab841..b657c46aa 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -274,6 +274,7 @@ public class Startup // v0.8.4 await MigrateLowestSeriesFolderPath2.Migrate(dataContext, unitOfWork, logger); await ManualMigrateRemovePeople.Migrate(dataContext, logger); + await MigrateDuplicateDarkTheme.Migrate(dataContext, logger); // Update the version in the DB after all migrations are run var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); diff --git a/UI/Web/src/app/_services/colorscape.service.ts b/UI/Web/src/app/_services/colorscape.service.ts index 8632c8218..95deace1e 100644 --- a/UI/Web/src/app/_services/colorscape.service.ts +++ b/UI/Web/src/app/_services/colorscape.service.ts @@ -1,6 +1,8 @@ import { Injectable, Inject } from '@angular/core'; import { DOCUMENT } from '@angular/common'; -import { BehaviorSubject } from 'rxjs'; +import {BehaviorSubject, filter, take, tap, timer} from 'rxjs'; +import {NavigationEnd, Router} from "@angular/router"; +import {debounceTime} from "rxjs/operators"; interface ColorSpace { primary: string; @@ -39,13 +41,41 @@ const colorScapeSelector = 'colorscape'; }) export class ColorscapeService { private colorSubject = new BehaviorSubject(null); + private colorSeedSubject = new BehaviorSubject<{primary: string, complementary: string | null} | null>(null); public readonly colors$ = this.colorSubject.asObservable(); private minDuration = 1000; // minimum duration private maxDuration = 4000; // maximum duration + private defaultColorspaceDuration = 300; // duration to wait before defaulting back to default colorspace - constructor(@Inject(DOCUMENT) private document: Document) { + constructor(@Inject(DOCUMENT) private document: Document, private readonly router: Router) { + this.router.events.pipe( + filter(event => event instanceof NavigationEnd), + tap(() => this.checkAndResetColorscapeAfterDelay()) + ).subscribe(); + + } + + /** + * Due to changing ColorScape on route end, we might go from one space to another, but the router events resets to default + * This delays it to see if the colors changed or not in 500ms and if not, then we will reset to default. + * @private + */ + private checkAndResetColorscapeAfterDelay() { + // Capture the current colors at the start of NavigationEnd + const initialColors = this.colorSubject.getValue(); + + // Wait for X ms, then check if colors have changed + timer(this.defaultColorspaceDuration).pipe( + take(1), // Complete after the timer emits + tap(() => { + const currentColors = this.colorSubject.getValue(); + if (initialColors != null && currentColors != null && this.areColorSpacesVisuallyEqual(initialColors, currentColors)) { + this.setColorScape(''); // Reset to default if colors haven't changed + } + }) + ).subscribe(); } /** @@ -64,6 +94,15 @@ export class ColorscapeService { return; } + // Check the old seed colors and check if they are similar, then avoid a change. In case you scan a series and this re-generates + const previousColors = this.colorSeedSubject.getValue(); + if (previousColors != null && primaryColor == previousColors.primary) { + this.colorSeedSubject.next({primary: primaryColor, complementary: complementaryColor}); + return; + } + + this.colorSeedSubject.next({primary: primaryColor, complementary: complementaryColor}); + const newColors: ColorSpace = primaryColor ? this.generateBackgroundColors(primaryColor, complementaryColor, this.isDarkTheme()) : this.defaultColors(); @@ -72,7 +111,6 @@ export class ColorscapeService { 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; @@ -156,7 +194,10 @@ export class ColorscapeService { 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); + // Add random variance to the duration + const durationVariance = this.getRandomInRange(-500, 500); + + return Math.round(duration + durationVariance); } private rgbaToRgb(rgba: RGBAColor): RGB { @@ -244,12 +285,19 @@ export class ColorscapeService { const primaryHSL = this.rgbToHsl(primary); const secondaryHSL = this.rgbToHsl(secondary); - if (isDarkTheme) { - return this.calculateDarkThemeColors(secondaryHSL, primaryHSL, primary); - } else { - // NOTE: Light themes look bad in general with this system. - return this.calculateLightThemeDarkColors(primaryHSL, primary); - } + return isDarkTheme + ? this.calculateDarkThemeColors(secondaryHSL, primaryHSL, primary) + : this.calculateLightThemeDarkColors(primaryHSL, primary); // NOTE: Light themes look bad in general with this system. + } + + private adjustColorWithVariance(color: string): string { + const rgb = this.hexToRgb(color); + const randomVariance = () => this.getRandomInRange(-10, 10); // Random variance for each color channel + return this.rgbToHex({ + r: Math.min(255, Math.max(0, rgb.r + randomVariance())), + g: Math.min(255, Math.max(0, rgb.g + randomVariance())), + b: Math.min(255, Math.max(0, rgb.b + randomVariance())) + }); } private calculateLightThemeDarkColors(primaryHSL: { h: number; s: number; l: number }, primary: RGB) { @@ -289,14 +337,62 @@ export class ColorscapeService { complementaryHSL.s = Math.min(complementaryHSL.s + 0.1, 1); complementaryHSL.l = Math.max(complementaryHSL.l - 0.2, 0.2); + // Array of colors to shuffle + const colors = [ + this.rgbToHex(primary), + this.rgbToHex(this.hslToRgb(lighterHSL)), + this.rgbToHex(this.hslToRgb(darkerHSL)), + this.rgbToHex(this.hslToRgb(complementaryHSL)) + ]; + + // Shuffle colors array + this.shuffleArray(colors); + + // Set a brightness threshold (you can adjust this value as needed) + const brightnessThreshold = 100; // Adjust based on your needs (0-255) + + // Ensure the 'lighter' color is not too bright + if (this.getBrightness(colors[1]) > brightnessThreshold) { + // If it is too bright, find a suitable swap + for (let i = 0; i < colors.length; i++) { + if (this.getBrightness(colors[i]) <= brightnessThreshold) { + // Swap colors[1] (lighter) with a less bright color + [colors[1], colors[i]] = [colors[i], colors[1]]; + break; + } + } + } + + // Ensure no color is repeating and variance is maintained + const uniqueColors = new Set(colors); + if (uniqueColors.size < colors.length) { + // If there are duplicates, re-shuffle the array + this.shuffleArray(colors); + } + return { - primary: this.rgbToHex(primary), - lighter: this.rgbToHex(this.hslToRgb(lighterHSL)), - darker: this.rgbToHex(this.hslToRgb(darkerHSL)), - complementary: this.rgbToHex(this.hslToRgb(complementaryHSL)) + primary: colors[0], + lighter: colors[1], + darker: colors[2], + complementary: colors[3] }; } + // Calculate brightness of a color (RGB) + private getBrightness(color: string) { + const rgb = this.hexToRgb(color); // Convert hex to RGB + // Using the luminance formula for brightness + return (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b); + } + + // Fisher-Yates shuffle algorithm + private shuffleArray(array: string[]) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + } + private hexToRgb(hex: string): RGB { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? { @@ -404,7 +500,7 @@ export class ColorscapeService { 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)); + private getRandomInRange(min: number, max: number): number { + return Math.random() * (max - min) + min; } } diff --git a/UI/Web/src/app/_services/person.service.ts b/UI/Web/src/app/_services/person.service.ts index c91b71691..00d4b98af 100644 --- a/UI/Web/src/app/_services/person.service.ts +++ b/UI/Web/src/app/_services/person.service.ts @@ -10,6 +10,7 @@ import {UtilityService} from "../shared/_services/utility.service"; import {BrowsePerson} from "../_models/person/browse-person"; import {Chapter} from "../_models/chapter"; import {StandaloneChapter} from "../_models/standalone-chapter"; +import {TextResonse} from "../_types/text-response"; @Injectable({ providedIn: 'root' @@ -50,4 +51,8 @@ export class PersonService { }) ); } + + downloadCover(personId: number) { + return this.httpClient.post(this.baseUrl + 'person/fetch-cover?personId=' + personId, {}, TextResonse); + } } diff --git a/UI/Web/src/app/_services/theme.service.ts b/UI/Web/src/app/_services/theme.service.ts index 26f1e46d9..95293baea 100644 --- a/UI/Web/src/app/_services/theme.service.ts +++ b/UI/Web/src/app/_services/theme.service.ts @@ -26,6 +26,7 @@ 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"; +import {debounceTime} from "rxjs/operators"; @Injectable({ providedIn: 'root' @@ -58,12 +59,6 @@ export class ThemeService { 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) { diff --git a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts index 8c27878ce..812ea6eb9 100644 --- a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts +++ b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts @@ -262,7 +262,7 @@ export class EditChapterModalComponent implements OnInit { const selectedIndex = this.editForm.get('coverImageIndex')?.value || 0; this.chapter.releaseDate = model.releaseDate; - this.chapter.ageRating = model.ageRating as AgeRating; + this.chapter.ageRating = parseInt(model.ageRating + '', 10) as AgeRating; this.chapter.genres = model.genres; this.chapter.tags = model.tags; this.chapter.sortOrder = model.sortOrder; diff --git a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html index c265e875c..064c7349b 100644 --- a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html +++ b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html @@ -26,7 +26,7 @@ [class.is-invalid]="settingsForm.get('taskScanCustom')?.invalid && settingsForm.get('taskScanCustom')?.touched" aria-describedby="task-scan-validations"> - @if (settingsForm.dirty || settingsForm.touched) { + @if (settingsForm.dirty || !settingsForm.untouched) {
@if(settingsForm.get('taskScanCustom')?.errors?.required) {
{{t('required')}}
@@ -65,7 +65,7 @@ [class.is-invalid]="settingsForm.get('taskBackupCustom')?.invalid && settingsForm.get('taskBackupCustom')?.touched" aria-describedby="task-scan-validations"> - @if (settingsForm.dirty || settingsForm.touched) { + @if (settingsForm.dirty || !settingsForm.untouched) {
@if(settingsForm.get('taskBackupCustom')?.errors?.required) {
{{t('required')}}
@@ -105,7 +105,7 @@ [class.is-invalid]="settingsForm.get('taskCleanupCustom')?.invalid && settingsForm.get('taskCleanupCustom')?.touched" aria-describedby="task-scan-validations"> - @if (settingsForm.dirty || settingsForm.touched) { + @if (settingsForm.dirty || !settingsForm.untouched) {
@if(settingsForm.get('taskCleanupCustom')?.errors?.required) {
{{t('required')}}
@@ -151,7 +151,7 @@ - @for(task of recurringTasks$ | async; track task.lastExecutionUtc + task.cron; let idx = $index) { + @for(task of recurringTasks$ | async; track task; let idx = $index) { {{task.title | titlecase}} diff --git a/UI/Web/src/app/cards/entity-title/entity-title.component.html b/UI/Web/src/app/cards/entity-title/entity-title.component.html index 92b30eaa2..4a41dbd11 100644 --- a/UI/Web/src/app/cards/entity-title/entity-title.component.html +++ b/UI/Web/src/app/cards/entity-title/entity-title.component.html @@ -1,94 +1,3 @@ {{renderText | defaultValue}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/UI/Web/src/app/cards/entity-title/entity-title.component.ts b/UI/Web/src/app/cards/entity-title/entity-title.component.ts index 8e9ea924c..665fb55c5 100644 --- a/UI/Web/src/app/cards/entity-title/entity-title.component.ts +++ b/UI/Web/src/app/cards/entity-title/entity-title.component.ts @@ -187,12 +187,26 @@ export class EntityTitleComponent implements OnInit { private calculateComicRenderText() { let renderText = ''; - if (this.titleName !== '' && this.prioritizeTitleName) { + + // If titleName is provided and prioritized + if (this.titleName && this.prioritizeTitleName) { if (this.isChapter && this.includeChapter) { renderText = translate('entity-title.issue-num') + ' ' + this.number + ' - '; } renderText += this.titleName; + } else { + // Otherwise, check volume and number logic + if (this.includeVolume && this.volumeTitle) { + if (this.number !== this.LooseLeafOrSpecial) { + renderText = this.isChapter ? this.volumeTitle : ''; + } + } + // Render either issue number or volume title, or "special" if applicable + renderText += this.number !== this.LooseLeafOrSpecial + ? (this.isChapter ? translate('entity-title.issue-num') + ' ' + this.number : this.volumeTitle) + : translate('entity-title.special'); } + return renderText; } } diff --git a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html index 7644fb67f..f5e2053b8 100644 --- a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html +++ b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html @@ -18,8 +18,8 @@ @if (editForm.get('name'); as formControl) { - + @if (formControl.errors; as errors) { @if (errors.required) {
{{t('required-field')}}
@@ -34,10 +34,10 @@
@if (editForm.get('malId'); as formControl) { - + + [class.is-invalid]="formControl.invalid && !formControl.untouched"> } @@ -45,10 +45,10 @@
@if (editForm.get('aniListId'); as formControl) { - + + [class.is-invalid]="formControl.invalid && !formControl.untouched"> } @@ -58,10 +58,10 @@
@if (editForm.get('hardcoverId'); as formControl) { - + + [class.is-invalid]="formControl.invalid && !formControl.untouched"> } @@ -69,10 +69,12 @@
@if (editForm.get('asin'); as formControl) { - + + [class.is-invalid]="formControl.invalid && !formControl.untouched"> + + } @@ -100,7 +102,11 @@ + + + { + if (imgUrl) { + this.toastr.success(translate('toasts.person-image-downloaded')); + this.fetchDisabled = true; + this.imageUrls.push(imgUrl); + this.cdRef.markForCheck(); + } + }); + } + } diff --git a/UI/Web/src/app/person-detail/person-detail.component.ts b/UI/Web/src/app/person-detail/person-detail.component.ts index 278016fb6..47a051411 100644 --- a/UI/Web/src/app/person-detail/person-detail.component.ts +++ b/UI/Web/src/app/person-detail/person-detail.component.ts @@ -4,14 +4,14 @@ import { Component, DestroyRef, ElementRef, Inject, - inject, + inject, OnInit, ViewChild } from '@angular/core'; import {ActivatedRoute, Router} from "@angular/router"; import {PersonService} from "../_services/person.service"; -import {Observable, switchMap, tap} from "rxjs"; +import {BehaviorSubject, EMPTY, Observable, switchMap, tap} from "rxjs"; import {Person, PersonRole} from "../_models/metadata/person"; -import {AsyncPipe, DOCUMENT, NgStyle} from "@angular/common"; +import {AsyncPipe, NgStyle} from "@angular/common"; import {ImageComponent} from "../shared/image/image.component"; import {ImageService} from "../_services/image.service"; import { @@ -80,7 +80,6 @@ export class PersonDetailComponent { @ViewChild('companionBar') companionBar: ElementRef | undefined; personName!: string; - person$: Observable | null = null; person: Person | null = null; roles$: Observable | null = null; roles: PersonRole[] | null = null; @@ -89,42 +88,50 @@ export class PersonDetailComponent { filter: SeriesFilterV2 | null = null; personActions: Array> = this.actionService.getPersonActions(this.handleAction.bind(this)); chaptersByRole: any = {}; + private readonly personSubject = new BehaviorSubject(null); + protected readonly person$ = this.personSubject.asObservable(); - constructor(@Inject(DOCUMENT) private document: Document) { - this.route.paramMap.subscribe(_ => { - const personName = this.route.snapshot.paramMap.get('name'); - if (personName === null || undefined) { - this.router.navigateByUrl('/home'); - return; - } + constructor() { + this.route.paramMap.pipe( + switchMap(params => { + const personName = params.get('name'); + if (!personName) { + this.router.navigateByUrl('/home'); + return EMPTY; + } - this.personName = personName; + this.personName = personName; + return this.personService.get(personName); + }), + tap(person => { + this.person = person; + this.personSubject.next(person); // emit the person data for subscribers + this.themeService.setColorScape(person.primaryColor || '', person.secondaryColor); + // Fetch roles and process them + this.roles$ = this.personService.getRolesForPerson(this.personName).pipe( + tap(roles => { + this.roles = roles; + this.filter = this.createFilter(roles); + this.chaptersByRole = {}; // Reset chaptersByRole for each person - this.person$ = this.personService.get(this.personName).pipe(tap(p => { - this.person = p; - - this.themeService.setColorScape(this.person.primaryColor || '', this.person.secondaryColor); - - this.roles$ = this.personService.getRolesForPerson(this.personName).pipe(tap(roles => { - this.roles = roles; - this.filter = this.createFilter(roles); - - for(let role of roles) { - this.chaptersByRole[role] = this.personService.getChaptersByRole(this.person!.id, role).pipe(takeUntilDestroyed(this.destroyRef)); - } - - this.cdRef.markForCheck(); - }), takeUntilDestroyed(this.destroyRef)); - - - this.works$ = this.personService.getSeriesMostKnownFor(this.person.id).pipe( + // Populate chapters by role + roles.forEach(role => { + this.chaptersByRole[role] = this.personService.getChaptersByRole(person.id, role) + .pipe(takeUntilDestroyed(this.destroyRef)); + }); + this.cdRef.markForCheck(); + }), takeUntilDestroyed(this.destroyRef) ); - this.cdRef.markForCheck(); - }), takeUntilDestroyed(this.destroyRef)); - }); + // Fetch series known for this person + this.works$ = this.personService.getSeriesMostKnownFor(person.id).pipe( + takeUntilDestroyed(this.destroyRef) + ); + }), + takeUntilDestroyed(this.destroyRef) + ).subscribe(); } createFilter(roles: PersonRole[]) { @@ -154,13 +161,7 @@ export class PersonDetailComponent { }; - if (this.person) { - loadPage(this.person).subscribe(); - } else { - this.person$?.pipe(switchMap((p: Person) => { - return loadPage(p); - })).subscribe(); - } + loadPage(this.person!).subscribe(); } loadFilterByRole(role: PersonRole) { @@ -191,7 +192,18 @@ export class PersonDetailComponent { ref.closed.subscribe(r => { if (r.success) { + const nameChanged = this.personName !== r.person.name; this.person = {...r.person}; + this.personName = this.person!.name; + + this.personSubject.next(this.person); + + // Update the url to reflect the new name change + if (nameChanged) { + const baseUrl = window.location.href.split('/').slice(0, -1).join('/'); + window.history.replaceState({}, '', `${baseUrl}/${encodeURIComponent(this.personName)}`); + } + this.cdRef.markForCheck(); } }); diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.html b/UI/Web/src/app/volume-detail/volume-detail.component.html index e39676fb6..822683b39 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.html +++ b/UI/Web/src/app/volume-detail/volume-detail.component.html @@ -191,21 +191,19 @@ } - @if (showDetailsTab) { -
  • - {{t('details-tab')}} - - @defer (when activeTabId === TabID.Details; prefetch on idle) { - - } - -
  • - } +
  • + {{t('details-tab')}} + + @defer (when activeTabId === TabID.Details; prefetch on idle) { + + } + +
  • diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.ts b/UI/Web/src/app/volume-detail/volume-detail.component.ts index 0738a3d73..4991271e4 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.ts +++ b/UI/Web/src/app/volume-detail/volume-detail.component.ts @@ -262,7 +262,6 @@ export class VolumeDetailComponent implements OnInit { * This is the download we get from download service. */ download$: Observable | null = null; - showDetailsTab: boolean = true; currentlyReadingChapter: Chapter | undefined = undefined; maxAgeRating: AgeRating = AgeRating.Unknown; @@ -506,7 +505,6 @@ export class VolumeDetailComponent implements OnInit { this.setContinuePoint(); - this.showDetailsTab = hasAnyCast(this.volumeCast) || (this.genres || []).length > 0 || (this.tags || []).length > 0; this.isLoading = false; this.cdRef.markForCheck(); }); diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index e99348d26..6309d57d7 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -2024,13 +2024,19 @@ "name-label": "{{edit-series-modal.name-label}}", "role-label": "Role", "mal-id-label": "MAL Id", + "mal-tooltip": "https://myanimelist.net/people/{MalId}/", "anilist-id-label": "AniList Id", + "anilist-tooltip": "https://anilist.co/staff/{AniListId}/", "hardcover-id-label": "Hardcover Id", + "hardcover-tooltip": "https://hardcover.app/authors/{HardcoverId}", "asin-label": "ASIN", + "asin-tooltip": "https://www.amazon.com/stores/J.K.-Rowling/author/{ASIN}", "description-label": "Description", "required-field": "{{validations.required-field}}", "cover-image-description": "{{edit-series-modal.cover-image-description}}", - "save": "{{common.save}}" + "cover-image-description-extra": "Alternatively you can download a cover from CoversDB if available.", + "save": "{{common.save}}", + "download-coversdb": "Download from CoversDB" }, "day-breakdown": { @@ -2424,7 +2430,8 @@ "confirm-reset-server-settings": "This will reset your settings to first install values. Are you sure you want to continue?", "must-select-library": "At least one library must be selected", "bulk-scan": "Scanning multiple libraries will be done linearly. This may take a long time and not complete depending on library size.", - "bulk-covers": "Refreshing covers on multiple libraries is intensive and can take a long time. Are you sure you want to continue?" + "bulk-covers": "Refreshing covers on multiple libraries is intensive and can take a long time. Are you sure you want to continue?", + "person-image-downloaded": "Person cover was downloaded and applied." }, "read-time-pipe": { diff --git a/UI/Web/src/main.ts b/UI/Web/src/main.ts index bc532b8a7..ca877b40c 100644 --- a/UI/Web/src/main.ts +++ b/UI/Web/src/main.ts @@ -99,7 +99,7 @@ const languageCodes = [ 'syr', 'syr_SY', 'ta', 'ta_IN', 'te', 'te_IN', 'th', 'th_TH', 'tl', 'tl_PH', 'tn', 'tn_ZA', 'tr', 'tr_TR', 'tt', 'tt_RU', 'ts', 'uk', 'uk_UA', 'ur', 'ur_PK', 'uz', 'uz_UZ', 'uz_UZ', 'vi', 'vi_VN', 'xh', 'xh_ZA', 'zh', 'zh_CN', 'zh_HK', 'zh_MO', - 'zh_SG', 'zh_TW', 'zu', 'zu_ZA', 'zh_Hans', 'zh_Hant', 'nb_NO' + 'zh_SG', 'zh_TW', 'zu', 'zu_ZA', 'zh_Hans', 'zh_Hant', 'nb_NO', 'ga' ]; const translocoOptions = {