mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-06-02 13:14:28 -04:00
Colorscape Love (#3326)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
parent
b44f89d1e8
commit
a847468a6c
@ -32,7 +32,7 @@ public class ArchiveServiceBenchmark
|
|||||||
public ArchiveServiceBenchmark()
|
public ArchiveServiceBenchmark()
|
||||||
{
|
{
|
||||||
_directoryService = new DirectoryService(null, new FileSystem());
|
_directoryService = new DirectoryService(null, new FileSystem());
|
||||||
_imageService = new ImageService(null, _directoryService, Substitute.For<IEasyCachingProviderFactory>());
|
_imageService = new ImageService(null, _directoryService);
|
||||||
_archiveService = new ArchiveService(new NullLogger<ArchiveService>(), _directoryService, _imageService, Substitute.For<IMediaErrorService>());
|
_archiveService = new ArchiveService(new NullLogger<ArchiveService>(), _directoryService, _imageService, Substitute.For<IMediaErrorService>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,7 +29,7 @@ public class ArchiveServiceTests
|
|||||||
{
|
{
|
||||||
_testOutputHelper = testOutputHelper;
|
_testOutputHelper = testOutputHelper;
|
||||||
_archiveService = new ArchiveService(_logger, _directoryService,
|
_archiveService = new ArchiveService(_logger, _directoryService,
|
||||||
new ImageService(Substitute.For<ILogger<ImageService>>(), _directoryService, Substitute.For<IEasyCachingProviderFactory>()),
|
new ImageService(Substitute.For<ILogger<ImageService>>(), _directoryService),
|
||||||
Substitute.For<IMediaErrorService>());
|
Substitute.For<IMediaErrorService>());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,7 +167,7 @@ public class ArchiveServiceTests
|
|||||||
public void GetCoverImage_Default_Test(string inputFile, string expectedOutputFile)
|
public void GetCoverImage_Default_Test(string inputFile, string expectedOutputFile)
|
||||||
{
|
{
|
||||||
var ds = Substitute.For<DirectoryService>(_directoryServiceLogger, new FileSystem());
|
var ds = Substitute.For<DirectoryService>(_directoryServiceLogger, new FileSystem());
|
||||||
var imageService = new ImageService(Substitute.For<ILogger<ImageService>>(), ds, Substitute.For<IEasyCachingProviderFactory>());
|
var imageService = new ImageService(Substitute.For<ILogger<ImageService>>(), ds);
|
||||||
var archiveService = Substitute.For<ArchiveService>(_logger, ds, imageService, Substitute.For<IMediaErrorService>());
|
var archiveService = Substitute.For<ArchiveService>(_logger, ds, imageService, Substitute.For<IMediaErrorService>());
|
||||||
|
|
||||||
var testDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages"));
|
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")]
|
[InlineData("sorting.zip", "sorting.expected.png")]
|
||||||
public void GetCoverImage_SharpCompress_Test(string inputFile, string expectedOutputFile)
|
public void GetCoverImage_SharpCompress_Test(string inputFile, string expectedOutputFile)
|
||||||
{
|
{
|
||||||
var imageService = new ImageService(Substitute.For<ILogger<ImageService>>(), _directoryService, Substitute.For<IEasyCachingProviderFactory>());
|
var imageService = new ImageService(Substitute.For<ILogger<ImageService>>(), _directoryService);
|
||||||
var archiveService = Substitute.For<ArchiveService>(_logger,
|
var archiveService = Substitute.For<ArchiveService>(_logger,
|
||||||
new DirectoryService(_directoryServiceLogger, new FileSystem()), imageService,
|
new DirectoryService(_directoryServiceLogger, new FileSystem()), imageService,
|
||||||
Substitute.For<IMediaErrorService>());
|
Substitute.For<IMediaErrorService>());
|
||||||
|
@ -17,7 +17,7 @@ public class BookServiceTests
|
|||||||
{
|
{
|
||||||
var directoryService = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new FileSystem());
|
var directoryService = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new FileSystem());
|
||||||
_bookService = new BookService(_logger, directoryService,
|
_bookService = new BookService(_logger, directoryService,
|
||||||
new ImageService(Substitute.For<ILogger<ImageService>>(), directoryService, Substitute.For<IEasyCachingProviderFactory>())
|
new ImageService(Substitute.For<ILogger<ImageService>>(), directoryService)
|
||||||
, Substitute.For<IMediaErrorService>());
|
, Substitute.For<IMediaErrorService>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -713,6 +713,9 @@ public class ReadingListServiceTests
|
|||||||
Assert.Equal("Issue #1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, "1", "1", "The Title")));
|
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("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")));
|
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
|
// Book Library & Archive
|
||||||
Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Book, "1")));
|
Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Book, "1")));
|
||||||
|
@ -341,6 +341,46 @@ public class ScannerServiceTests : AbstractDbTest
|
|||||||
Assert.Equal(4, series.Volumes.First().Chapters.Count);
|
Assert.Equal(4, series.Volumes.First().Chapters.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This is the same as doing ScanFolder as the case where it can find the series is just ScanSeries
|
||||||
|
/// </summary>
|
||||||
|
[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<string, ComicInfo>();
|
||||||
|
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
|
#region Setup
|
||||||
private async Task<Library> GenerateScannerData(string testcase, Dictionary<string, ComicInfo> comicInfos = null)
|
private async Task<Library> GenerateScannerData(string testcase, Dictionary<string, ComicInfo> comicInfos = null)
|
||||||
|
@ -2080,7 +2080,7 @@ public class SeriesServiceTests : AbstractDbTest
|
|||||||
public async Task GetEstimatedChapterCreationDate_NextChapter_ChaptersMonthApart()
|
public async Task GetEstimatedChapterCreationDate_NextChapter_ChaptersMonthApart()
|
||||||
{
|
{
|
||||||
await ResetDb();
|
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")
|
_context.Library.Add(new LibraryBuilder("Test LIb")
|
||||||
.WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build())
|
.WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build())
|
||||||
@ -2103,6 +2103,7 @@ public class SeriesServiceTests : AbstractDbTest
|
|||||||
Assert.Equal(Parser.LooseLeafVolumeNumber, nextChapter.VolumeNumber);
|
Assert.Equal(Parser.LooseLeafVolumeNumber, nextChapter.VolumeNumber);
|
||||||
Assert.Equal(5, nextChapter.ChapterNumber);
|
Assert.Equal(5, nextChapter.ChapterNumber);
|
||||||
Assert.NotNull(nextChapter.ExpectedDate);
|
Assert.NotNull(nextChapter.ExpectedDate);
|
||||||
|
|
||||||
var expected = now.AddMonths(4);
|
var expected = now.AddMonths(4);
|
||||||
Assert.Equal(expected.Month, nextChapter.ExpectedDate.Value.Month);
|
Assert.Equal(expected.Month, nextChapter.ExpectedDate.Value.Month);
|
||||||
Assert.True(nextChapter.ExpectedDate.Value.Day >= expected.Day - 1 || nextChapter.ExpectedDate.Value.Day <= expected.Day + 1);
|
Assert.True(nextChapter.ExpectedDate.Value.Day >= expected.Day - 1 || nextChapter.ExpectedDate.Value.Day <= expected.Day + 1);
|
||||||
|
@ -106,6 +106,7 @@
|
|||||||
<PackageReference Include="System.IO.Abstractions" Version="21.0.29" />
|
<PackageReference Include="System.IO.Abstractions" Version="21.0.29" />
|
||||||
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
|
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
|
||||||
<PackageReference Include="VersOne.Epub" Version="3.3.2" />
|
<PackageReference Include="VersOne.Epub" Version="3.3.2" />
|
||||||
|
<PackageReference Include="YamlDotNet" Version="16.1.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
@ -7,6 +7,7 @@ using API.Data;
|
|||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
|
using API.Services.Tasks.Metadata;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using MimeTypes;
|
using MimeTypes;
|
||||||
@ -26,17 +27,19 @@ public class ImageController : BaseApiController
|
|||||||
private readonly IImageService _imageService;
|
private readonly IImageService _imageService;
|
||||||
private readonly ILocalizationService _localizationService;
|
private readonly ILocalizationService _localizationService;
|
||||||
private readonly IReadingListService _readingListService;
|
private readonly IReadingListService _readingListService;
|
||||||
|
private readonly ICoverDbService _coverDbService;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService,
|
public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService,
|
||||||
IImageService imageService, ILocalizationService localizationService,
|
IImageService imageService, ILocalizationService localizationService,
|
||||||
IReadingListService readingListService)
|
IReadingListService readingListService, ICoverDbService coverDbService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_directoryService = directoryService;
|
_directoryService = directoryService;
|
||||||
_imageService = imageService;
|
_imageService = imageService;
|
||||||
_localizationService = localizationService;
|
_localizationService = localizationService;
|
||||||
_readingListService = readingListService;
|
_readingListService = readingListService;
|
||||||
|
_coverDbService = coverDbService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -230,7 +233,7 @@ public class ImageController : BaseApiController
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.FaviconDirectory,
|
domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.FaviconDirectory,
|
||||||
await _imageService.DownloadFaviconAsync(url, encodeFormat));
|
await _coverDbService.DownloadFaviconAsync(url, encodeFormat));
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
@ -270,7 +273,7 @@ public class ImageController : BaseApiController
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.PublisherDirectory,
|
domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.PublisherDirectory,
|
||||||
await _imageService.DownloadPublisherImageAsync(publisherName, encodeFormat));
|
await _coverDbService.DownloadPublisherImageAsync(publisherName, encodeFormat));
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
|
@ -6,6 +6,8 @@ using API.Entities.Enums;
|
|||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Helpers;
|
using API.Helpers;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
|
using API.Services.Tasks.Metadata;
|
||||||
|
using API.SignalR;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nager.ArticleNumber;
|
using Nager.ArticleNumber;
|
||||||
@ -18,12 +20,19 @@ public class PersonController : BaseApiController
|
|||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly ILocalizationService _localizationService;
|
private readonly ILocalizationService _localizationService;
|
||||||
private readonly IMapper _mapper;
|
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;
|
_unitOfWork = unitOfWork;
|
||||||
_localizationService = localizationService;
|
_localizationService = localizationService;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
|
_coverDbService = coverDbService;
|
||||||
|
_imageService = imageService;
|
||||||
|
_eventHub = eventHub;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -65,8 +74,17 @@ public class PersonController : BaseApiController
|
|||||||
var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id);
|
var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id);
|
||||||
if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist"));
|
if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist"));
|
||||||
|
|
||||||
dto.Description ??= string.Empty;
|
if (string.IsNullOrEmpty(dto.Name)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-required"));
|
||||||
person.Description = dto.Description;
|
|
||||||
|
|
||||||
|
// 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;
|
person.CoverImageLocked = dto.CoverImageLocked;
|
||||||
|
|
||||||
if (dto.MalId is > 0)
|
if (dto.MalId is > 0)
|
||||||
@ -96,6 +114,26 @@ public class PersonController : BaseApiController
|
|||||||
return Ok(_mapper.Map<PersonDto>(person));
|
return Ok(_mapper.Map<PersonDto>(person));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("fetch-cover")]
|
||||||
|
public async Task<ActionResult<string>> 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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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
|
/// 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
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -162,7 +162,7 @@ public class ReadingListController : BaseApiController
|
|||||||
return Ok(await _localizationService.Translate(User.GetUserId(), "reading-list-updated"));
|
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"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
16
API/DTOs/CoverDb/CoverDbAuthor.cs
Normal file
16
API/DTOs/CoverDb/CoverDbAuthor.cs
Normal file
@ -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<string> Aliases { get; set; } = new List<string>();
|
||||||
|
[YamlMember(Alias = "ids", ApplyNamingConventions = false)]
|
||||||
|
public CoverDbPersonIds Ids { get; set; }
|
||||||
|
[YamlMember(Alias = "image_path", ApplyNamingConventions = false)]
|
||||||
|
public string ImagePath { get; set; }
|
||||||
|
}
|
10
API/DTOs/CoverDb/CoverDbPeople.cs
Normal file
10
API/DTOs/CoverDb/CoverDbPeople.cs
Normal file
@ -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<CoverDbAuthor> People { get; set; } = new List<CoverDbAuthor>();
|
||||||
|
}
|
20
API/DTOs/CoverDb/CoverDbPersonIds.cs
Normal file
20
API/DTOs/CoverDb/CoverDbPersonIds.cs
Normal file
@ -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;
|
||||||
|
}
|
@ -8,6 +8,8 @@ public class UpdatePersonDto
|
|||||||
public int Id { get; init; }
|
public int Id { get; init; }
|
||||||
[Required]
|
[Required]
|
||||||
public bool CoverImageLocked { get; set; }
|
public bool CoverImageLocked { get; set; }
|
||||||
|
[Required]
|
||||||
|
public string Name {get; set;}
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
|
|
||||||
public int? AniListId { get; set; }
|
public int? AniListId { get; set; }
|
||||||
|
@ -43,4 +43,6 @@ public class ReadingListItemDto
|
|||||||
/// The chapter summary
|
/// The chapter summary
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Summary { get; set; }
|
public string? Summary { get; set; }
|
||||||
|
|
||||||
|
public bool IsSpecial { get; set; }
|
||||||
}
|
}
|
||||||
|
67
API/Data/ManualMigrations/MigrateDuplicateDarkTheme.cs
Normal file
67
API/Data/ManualMigrations/MigrateDuplicateDarkTheme.cs
Normal file
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v0.8.0 ensured that MangaFile Path is normalized. This will normalize existing data to avoid churn.
|
||||||
|
/// </summary>
|
||||||
|
public static class MigrateDuplicateDarkTheme
|
||||||
|
{
|
||||||
|
public static async Task Migrate(DataContext dataContext, ILogger<Program> 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");
|
||||||
|
}
|
||||||
|
}
|
85
API/Data/Repositories/CoverDbRepository.cs
Normal file
85
API/Data/Repositories/CoverDbRepository.cs
Normal file
@ -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
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This is a manual repository, not a DB repo
|
||||||
|
/// </summary>
|
||||||
|
public class CoverDbRepository
|
||||||
|
{
|
||||||
|
private readonly List<CoverDbAuthor> _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<CoverDbPeople>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -38,6 +38,7 @@ public interface IPersonRepository
|
|||||||
Task<Person?> GetPersonById(int personId);
|
Task<Person?> GetPersonById(int personId);
|
||||||
Task<PersonDto?> GetPersonDtoByName(string name, int userId);
|
Task<PersonDto?> GetPersonDtoByName(string name, int userId);
|
||||||
Task<Person> GetPersonByName(string name);
|
Task<Person> GetPersonByName(string name);
|
||||||
|
Task<bool> IsNameUnique(string name);
|
||||||
|
|
||||||
Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId);
|
Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId);
|
||||||
Task<IEnumerable<StandaloneChapterDto>> GetChaptersForPersonByRole(int personId, int userId, PersonRole role);
|
Task<IEnumerable<StandaloneChapterDto>> 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());
|
return await _context.Person.FirstOrDefaultAsync(p => p.NormalizedName == name.ToNormalized());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> IsNameUnique(string name)
|
||||||
|
{
|
||||||
|
return !(await _context.Person.AnyAsync(p => p.Name == name));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId)
|
public async Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId)
|
||||||
{
|
{
|
||||||
return await _context.Person
|
return await _context.Person
|
||||||
|
@ -248,6 +248,7 @@ public class ReadingListRepository : IReadingListRepository
|
|||||||
ChapterTitleName = chapter.TitleName,
|
ChapterTitleName = chapter.TitleName,
|
||||||
FileSize = chapter.Files.Sum(f => f.Bytes),
|
FileSize = chapter.Files.Sum(f => f.Bytes),
|
||||||
chapter.Summary,
|
chapter.Summary,
|
||||||
|
chapter.IsSpecial
|
||||||
|
|
||||||
})
|
})
|
||||||
.Join(_context.Volume, s => s.ReadingListItem.VolumeId, volume => volume.Id, (data, volume) => new
|
.Join(_context.Volume, s => s.ReadingListItem.VolumeId, volume => volume.Id, (data, volume) => new
|
||||||
@ -259,6 +260,7 @@ public class ReadingListRepository : IReadingListRepository
|
|||||||
data.ChapterTitleName,
|
data.ChapterTitleName,
|
||||||
data.FileSize,
|
data.FileSize,
|
||||||
data.Summary,
|
data.Summary,
|
||||||
|
data.IsSpecial,
|
||||||
VolumeId = volume.Id,
|
VolumeId = volume.Id,
|
||||||
VolumeNumber = volume.Name,
|
VolumeNumber = volume.Name,
|
||||||
})
|
})
|
||||||
@ -277,6 +279,7 @@ public class ReadingListRepository : IReadingListRepository
|
|||||||
data.ChapterTitleName,
|
data.ChapterTitleName,
|
||||||
data.FileSize,
|
data.FileSize,
|
||||||
data.Summary,
|
data.Summary,
|
||||||
|
data.IsSpecial,
|
||||||
LibraryName = _context.Library.Where(l => l.Id == s.LibraryId).Select(l => l.Name).Single(),
|
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()
|
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,
|
ChapterTitleName = data.ChapterTitleName,
|
||||||
LibraryName = data.LibraryName,
|
LibraryName = data.LibraryName,
|
||||||
FileSize = data.FileSize,
|
FileSize = data.FileSize,
|
||||||
Summary = data.Summary
|
Summary = data.Summary,
|
||||||
|
IsSpecial = data.IsSpecial
|
||||||
})
|
})
|
||||||
.Where(o => userLibraries.Contains(o.LibraryId))
|
.Where(o => userLibraries.Contains(o.LibraryId))
|
||||||
.OrderBy(rli => rli.Order)
|
.OrderBy(rli => rli.Order)
|
||||||
|
@ -68,6 +68,7 @@ public static class ApplicationServiceExtensions
|
|||||||
services.AddScoped<IEventHub, EventHub>();
|
services.AddScoped<IEventHub, EventHub>();
|
||||||
services.AddScoped<IPresenceTracker, PresenceTracker>();
|
services.AddScoped<IPresenceTracker, PresenceTracker>();
|
||||||
services.AddScoped<IImageService, ImageService>();
|
services.AddScoped<IImageService, ImageService>();
|
||||||
|
services.AddScoped<ICoverDbService, CoverDbService>();
|
||||||
|
|
||||||
services.AddScoped<ILocalizationService, LocalizationService>();
|
services.AddScoped<ILocalizationService, LocalizationService>();
|
||||||
|
|
||||||
|
26
API/Extensions/DoubleExtensions.cs
Normal file
26
API/Extensions/DoubleExtensions.cs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace API.Extensions;
|
||||||
|
|
||||||
|
public static class DoubleExtensions
|
||||||
|
{
|
||||||
|
private const float Tolerance = 0.001f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Used to compare 2 floats together
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="a"></param>
|
||||||
|
/// <param name="b"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
using System.Globalization;
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace API.Extensions;
|
namespace API.Extensions;
|
||||||
@ -10,6 +11,23 @@ public static class StringExtensions
|
|||||||
RegexOptions.ExplicitCapture | RegexOptions.Compiled,
|
RegexOptions.ExplicitCapture | RegexOptions.Compiled,
|
||||||
Services.Tasks.Scanner.Parser.Parser.RegexTimeout);
|
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)
|
public static string SentenceCase(this string value)
|
||||||
{
|
{
|
||||||
return SentenceCaseRegex.Replace(value.ToLower(), s => s.Value.ToUpper());
|
return SentenceCaseRegex.Replace(value.ToLower(), s => s.Value.ToUpper());
|
||||||
|
@ -53,6 +53,9 @@
|
|||||||
"error-import-stack": "There was an issue importing MAL stack",
|
"error-import-stack": "There was an issue importing MAL stack",
|
||||||
|
|
||||||
"person-doesnt-exist": "Person does not exist",
|
"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",
|
"device-doesnt-exist": "Device does not exist",
|
||||||
"generic-device-create": "There was an error when creating the device",
|
"generic-device-create": "There was an error when creating the device",
|
||||||
@ -61,7 +64,7 @@
|
|||||||
"greater-0": "{0} must be greater than 0",
|
"greater-0": "{0} must be greater than 0",
|
||||||
"send-to-kavita-email": "Send to device cannot be used without Email setup",
|
"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-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",
|
"send-to-device-status": "Transferring files to your device",
|
||||||
"generic-send-to": "There was an error sending the file(s) to the device",
|
"generic-send-to": "There was an error sending the file(s) to the device",
|
||||||
"series-doesnt-exist": "Series does not exist",
|
"series-doesnt-exist": "Series does not exist",
|
||||||
|
@ -7,6 +7,7 @@ using System.Numerics;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Constants;
|
using API.Constants;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Entities.Interfaces;
|
using API.Entities.Interfaces;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
@ -66,17 +67,15 @@ public interface IImageService
|
|||||||
/// <returns>File of written encoded image</returns>
|
/// <returns>File of written encoded image</returns>
|
||||||
Task<string> ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat);
|
Task<string> ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat);
|
||||||
Task<bool> IsImage(string filePath);
|
Task<bool> IsImage(string filePath);
|
||||||
Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat);
|
|
||||||
Task<string> DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat);
|
|
||||||
void UpdateColorScape(IHasCoverImage entity);
|
void UpdateColorScape(IHasCoverImage entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ImageService : IImageService
|
public class ImageService : IImageService
|
||||||
{
|
{
|
||||||
public const string Name = "BookmarkService";
|
public const string Name = "ImageService";
|
||||||
private readonly ILogger<ImageService> _logger;
|
private readonly ILogger<ImageService> _logger;
|
||||||
private readonly IDirectoryService _directoryService;
|
private readonly IDirectoryService _directoryService;
|
||||||
private readonly IEasyCachingProviderFactory _cacheFactory;
|
|
||||||
public const string ChapterCoverImageRegex = @"v\d+_c\d+";
|
public const string ChapterCoverImageRegex = @"v\d+_c\d+";
|
||||||
public const string SeriesCoverImageRegex = @"series\d+";
|
public const string SeriesCoverImageRegex = @"series\d+";
|
||||||
public const string CollectionTagCoverImageRegex = @"tag\d+";
|
public const string CollectionTagCoverImageRegex = @"tag\d+";
|
||||||
@ -100,26 +99,10 @@ public class ImageService : IImageService
|
|||||||
public const int LibraryThumbnailWidth = 32;
|
public const int LibraryThumbnailWidth = 32;
|
||||||
|
|
||||||
|
|
||||||
private static readonly string[] ValidIconRelations = {
|
public ImageService(ILogger<ImageService> logger, IDirectoryService directoryService)
|
||||||
"icon",
|
|
||||||
"apple-touch-icon",
|
|
||||||
"apple-touch-icon-precomposed",
|
|
||||||
"apple-touch-icon icon-precomposed" // ComicVine has it combined
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A mapping of urls that need to get the icon from another url, due to strangeness (like app.plex.tv loading a black icon)
|
|
||||||
/// </summary>
|
|
||||||
private static readonly IDictionary<string, string> FaviconUrlMapper = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["https://app.plex.tv"] = "https://plex.tv"
|
|
||||||
};
|
|
||||||
|
|
||||||
public ImageService(ILogger<ImageService> logger, IDirectoryService directoryService, IEasyCachingProviderFactory cacheFactory)
|
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_directoryService = directoryService;
|
_directoryService = directoryService;
|
||||||
_cacheFactory = cacheFactory;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ExtractImages(string? fileFilePath, string targetDirectory, int fileCount = 1)
|
public void ExtractImages(string? fileFilePath, string targetDirectory, int fileCount = 1)
|
||||||
@ -335,151 +318,8 @@ public class ImageService : IImageService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> 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<string>(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<string> DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var publisherLink = await FallbackToKavitaReaderPublisher(publisherName);
|
|
||||||
if (string.IsNullOrEmpty(publisherLink))
|
|
||||||
{
|
|
||||||
throw new KavitaException($"Could not grab publisher image for {publisherName}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var finalUrl = publisherLink;
|
|
||||||
|
|
||||||
_logger.LogTrace("Fetching publisher image from {Url}", finalUrl);
|
|
||||||
// Download the favicon.ico file using Flurl
|
|
||||||
var publisherStream = await finalUrl
|
|
||||||
.AllowHttpStatus("2xx,304")
|
|
||||||
.GetStreamAsync();
|
|
||||||
|
|
||||||
// Create the destination file path
|
|
||||||
using var image = Image.PngloadStream(publisherStream);
|
|
||||||
var filename = GetPublisherFormat(publisherName, encodeFormat);
|
|
||||||
switch (encodeFormat)
|
|
||||||
{
|
|
||||||
case EncodeFormat.PNG:
|
|
||||||
image.Pngsave(Path.Combine(_directoryService.PublisherDirectory, filename));
|
|
||||||
break;
|
|
||||||
case EncodeFormat.WEBP:
|
|
||||||
image.Webpsave(Path.Combine(_directoryService.PublisherDirectory, filename));
|
|
||||||
break;
|
|
||||||
case EncodeFormat.AVIF:
|
|
||||||
image.Heifsave(Path.Combine(_directoryService.PublisherDirectory, filename));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_logger.LogDebug("Publisher image for {PublisherName} downloaded and saved successfully", publisherName);
|
|
||||||
return filename;
|
|
||||||
} catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error downloading image for {PublisherName}", publisherName);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static (Vector3?, Vector3?) GetPrimarySecondaryColors(string imagePath)
|
private static (Vector3?, Vector3?) GetPrimarySecondaryColors(string imagePath)
|
||||||
{
|
{
|
||||||
using var image = Image.NewFromFile(imagePath);
|
using var image = Image.NewFromFile(imagePath);
|
||||||
@ -740,63 +580,7 @@ public class ImageService : IImageService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<string> 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<string> FallbackToKavitaReaderPublisher(string publisherName)
|
|
||||||
{
|
|
||||||
var externalLink = string.Empty;
|
|
||||||
var allOverrides = await "https://www.kavitareader.com/assets/publishers/publishers.txt".GetStringAsync();
|
|
||||||
if (!string.IsNullOrEmpty(allOverrides))
|
|
||||||
{
|
|
||||||
var externalFile = allOverrides
|
|
||||||
.Split("\n")
|
|
||||||
.Select(publisherLine =>
|
|
||||||
{
|
|
||||||
var tokens = publisherLine.Split("|");
|
|
||||||
if (tokens.Length != 2) return null;
|
|
||||||
var aliases = tokens[0];
|
|
||||||
// Multiple publisher aliases are separated by #
|
|
||||||
if (aliases.Split("#").Any(name => name.ToLowerInvariant().Trim().Equals(publisherName.ToLowerInvariant().Trim())))
|
|
||||||
{
|
|
||||||
return tokens[1];
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.FirstOrDefault(url => !string.IsNullOrEmpty(url));
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(externalFile))
|
|
||||||
{
|
|
||||||
throw new KavitaException($"Could not grab publisher image for {publisherName}");
|
|
||||||
}
|
|
||||||
|
|
||||||
externalLink = "https://www.kavitareader.com/assets/publishers/" + externalFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
return externalLink;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = ThumbnailWidth)
|
public string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = ThumbnailWidth)
|
||||||
|
@ -107,15 +107,30 @@ public class ReadingListService : IReadingListService
|
|||||||
|
|
||||||
if (title != string.Empty) return title;
|
if (title != string.Empty) return title;
|
||||||
|
|
||||||
|
// item.ChapterNumber is Range
|
||||||
if (item.ChapterNumber == Parser.DefaultChapter &&
|
if (item.ChapterNumber == Parser.DefaultChapter &&
|
||||||
!string.IsNullOrEmpty(item.ChapterTitleName))
|
!string.IsNullOrEmpty(item.ChapterTitleName))
|
||||||
{
|
{
|
||||||
title = 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
|
else
|
||||||
{
|
{
|
||||||
title = ReaderService.FormatChapterName(item.LibraryType, true, true) + chapterNum;
|
title = ReaderService.FormatChapterName(item.LibraryType, true, true) + chapterNum;
|
||||||
}
|
}
|
||||||
|
|
||||||
return title;
|
return title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -910,9 +910,14 @@ public class SeriesService : ISeriesService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the forecast for when the next chapter is expected
|
// Calculate the forecast for when the next chapter is expected
|
||||||
var nextChapterExpected = chapters.Any()
|
// var nextChapterExpected = chapters.Count > 0
|
||||||
? chapters.Max(c => c.CreatedUtc) + TimeSpan.FromDays(forecastedTimeDifference)
|
// ? chapters.Max(c => c.CreatedUtc) + TimeSpan.FromDays(forecastedTimeDifference)
|
||||||
: (DateTime?)null;
|
// : (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
|
// For number and volume number, we need the highest chapter, not the latest created
|
||||||
var lastChapter = chapters.MaxBy(c => c.MaxNumber)!;
|
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.Manga => await _localizationService.Translate(userId, "chapter-num", result.ChapterNumber),
|
||||||
LibraryType.Comic => await _localizationService.Translate(userId, "issue-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.Book => await _localizationService.Translate(userId, "book-num", result.ChapterNumber),
|
||||||
LibraryType.LightNovel => 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)
|
_ => await _localizationService.Translate(userId, "chapter-num", result.ChapterNumber)
|
||||||
|
372
API/Services/Tasks/Metadata/CoverDbService.cs
Normal file
372
API/Services/Tasks/Metadata/CoverDbService.cs
Normal file
@ -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<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat);
|
||||||
|
Task<string> DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat);
|
||||||
|
Task<string?> DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public class CoverDbService : ICoverDbService
|
||||||
|
{
|
||||||
|
private readonly ILogger<CoverDbService> _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
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A mapping of urls that need to get the icon from another url, due to strangeness (like app.plex.tv loading a black icon)
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Dictionary<string, string> FaviconUrlMapper = new()
|
||||||
|
{
|
||||||
|
["https://app.plex.tv"] = "https://plex.tv"
|
||||||
|
};
|
||||||
|
|
||||||
|
public CoverDbService(ILogger<CoverDbService> logger, IDirectoryService directoryService,
|
||||||
|
IEasyCachingProviderFactory cacheFactory, IHostEnvironment env)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_directoryService = directoryService;
|
||||||
|
_cacheFactory = cacheFactory;
|
||||||
|
_env = env;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> 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<string>(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<string> DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var publisherLink = await FallbackToKavitaReaderPublisher(publisherName);
|
||||||
|
if (string.IsNullOrEmpty(publisherLink))
|
||||||
|
{
|
||||||
|
throw new KavitaException($"Could not grab publisher image for {publisherName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to download the Person image from CoverDB while matching against metadata within the Person
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="person"></param>
|
||||||
|
/// <param name="encodeFormat"></param>
|
||||||
|
/// <returns>Person image (in correct directory) or null if not found/error</returns>
|
||||||
|
public async Task<string?> 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<string> 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<string> 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<string> 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;
|
||||||
|
}
|
||||||
|
}
|
@ -274,6 +274,7 @@ public class Startup
|
|||||||
// v0.8.4
|
// v0.8.4
|
||||||
await MigrateLowestSeriesFolderPath2.Migrate(dataContext, unitOfWork, logger);
|
await MigrateLowestSeriesFolderPath2.Migrate(dataContext, unitOfWork, logger);
|
||||||
await ManualMigrateRemovePeople.Migrate(dataContext, logger);
|
await ManualMigrateRemovePeople.Migrate(dataContext, logger);
|
||||||
|
await MigrateDuplicateDarkTheme.Migrate(dataContext, logger);
|
||||||
|
|
||||||
// Update the version in the DB after all migrations are run
|
// Update the version in the DB after all migrations are run
|
||||||
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
|
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { Injectable, Inject } from '@angular/core';
|
import { Injectable, Inject } from '@angular/core';
|
||||||
import { DOCUMENT } from '@angular/common';
|
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 {
|
interface ColorSpace {
|
||||||
primary: string;
|
primary: string;
|
||||||
@ -39,13 +41,41 @@ const colorScapeSelector = 'colorscape';
|
|||||||
})
|
})
|
||||||
export class ColorscapeService {
|
export class ColorscapeService {
|
||||||
private colorSubject = new BehaviorSubject<ColorSpaceRGBA | null>(null);
|
private colorSubject = new BehaviorSubject<ColorSpaceRGBA | null>(null);
|
||||||
|
private colorSeedSubject = new BehaviorSubject<{primary: string, complementary: string | null} | null>(null);
|
||||||
public readonly colors$ = this.colorSubject.asObservable();
|
public readonly colors$ = this.colorSubject.asObservable();
|
||||||
|
|
||||||
private minDuration = 1000; // minimum duration
|
private minDuration = 1000; // minimum duration
|
||||||
private maxDuration = 4000; // maximum 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;
|
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 ?
|
const newColors: ColorSpace = primaryColor ?
|
||||||
this.generateBackgroundColors(primaryColor, complementaryColor, this.isDarkTheme()) :
|
this.generateBackgroundColors(primaryColor, complementaryColor, this.isDarkTheme()) :
|
||||||
this.defaultColors();
|
this.defaultColors();
|
||||||
@ -72,7 +111,6 @@ export class ColorscapeService {
|
|||||||
const oldColors = this.colorSubject.getValue() || this.convertColorsToRGBA(this.defaultColors());
|
const oldColors = this.colorSubject.getValue() || this.convertColorsToRGBA(this.defaultColors());
|
||||||
const duration = this.calculateTransitionDuration(oldColors, newColorsRGBA);
|
const duration = this.calculateTransitionDuration(oldColors, newColorsRGBA);
|
||||||
|
|
||||||
|
|
||||||
// Check if the colors we are transitioning to are visually equal
|
// Check if the colors we are transitioning to are visually equal
|
||||||
if (this.areColorSpacesVisuallyEqual(oldColors, newColorsRGBA)) {
|
if (this.areColorSpacesVisuallyEqual(oldColors, newColorsRGBA)) {
|
||||||
return;
|
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 normalizedDistance = Math.min(totalDistance / (255 * 3 * 4), 1); // Max possible distance is 255*3*4
|
||||||
const duration = this.minDuration + normalizedDistance * (this.maxDuration - this.minDuration);
|
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 {
|
private rgbaToRgb(rgba: RGBAColor): RGB {
|
||||||
@ -244,12 +285,19 @@ export class ColorscapeService {
|
|||||||
const primaryHSL = this.rgbToHsl(primary);
|
const primaryHSL = this.rgbToHsl(primary);
|
||||||
const secondaryHSL = this.rgbToHsl(secondary);
|
const secondaryHSL = this.rgbToHsl(secondary);
|
||||||
|
|
||||||
if (isDarkTheme) {
|
return isDarkTheme
|
||||||
return this.calculateDarkThemeColors(secondaryHSL, primaryHSL, primary);
|
? this.calculateDarkThemeColors(secondaryHSL, primaryHSL, primary)
|
||||||
} else {
|
: this.calculateLightThemeDarkColors(primaryHSL, primary); // NOTE: Light themes look bad in general with this system.
|
||||||
// NOTE: Light themes look bad in general with this system.
|
|
||||||
return this.calculateLightThemeDarkColors(primaryHSL, primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
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.s = Math.min(complementaryHSL.s + 0.1, 1);
|
||||||
complementaryHSL.l = Math.max(complementaryHSL.l - 0.2, 0.2);
|
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 {
|
return {
|
||||||
primary: this.rgbToHex(primary),
|
primary: colors[0],
|
||||||
lighter: this.rgbToHex(this.hslToRgb(lighterHSL)),
|
lighter: colors[1],
|
||||||
darker: this.rgbToHex(this.hslToRgb(darkerHSL)),
|
darker: colors[2],
|
||||||
complementary: this.rgbToHex(this.hslToRgb(complementaryHSL))
|
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 {
|
private hexToRgb(hex: string): RGB {
|
||||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
return result ? {
|
return result ? {
|
||||||
@ -404,7 +500,7 @@ export class ColorscapeService {
|
|||||||
styleElement.textContent = styles;
|
styleElement.textContent = styles;
|
||||||
}
|
}
|
||||||
|
|
||||||
private unsetPageColorOverrides() {
|
private getRandomInRange(min: number, max: number): number {
|
||||||
Array.from(this.document.head.children).filter(el => el.tagName === 'STYLE' && el.id.toLowerCase() === colorScapeSelector).forEach(c => this.document.head.removeChild(c));
|
return Math.random() * (max - min) + min;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ import {UtilityService} from "../shared/_services/utility.service";
|
|||||||
import {BrowsePerson} from "../_models/person/browse-person";
|
import {BrowsePerson} from "../_models/person/browse-person";
|
||||||
import {Chapter} from "../_models/chapter";
|
import {Chapter} from "../_models/chapter";
|
||||||
import {StandaloneChapter} from "../_models/standalone-chapter";
|
import {StandaloneChapter} from "../_models/standalone-chapter";
|
||||||
|
import {TextResonse} from "../_types/text-response";
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@ -50,4 +51,8 @@ export class PersonService {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
downloadCover(personId: number) {
|
||||||
|
return this.httpClient.post<string>(this.baseUrl + 'person/fetch-cover?personId=' + personId, {}, TextResonse);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,6 +26,7 @@ import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event"
|
|||||||
import {NavigationEnd, Router} from "@angular/router";
|
import {NavigationEnd, Router} from "@angular/router";
|
||||||
import {ColorscapeService} from "./colorscape.service";
|
import {ColorscapeService} from "./colorscape.service";
|
||||||
import {ColorScape} from "../_models/theme/colorscape";
|
import {ColorScape} from "../_models/theme/colorscape";
|
||||||
|
import {debounceTime} from "rxjs/operators";
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@ -58,12 +59,6 @@ export class ThemeService {
|
|||||||
private router: Router) {
|
private router: Router) {
|
||||||
this.renderer = rendererFactory.createRenderer(null, null);
|
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 => {
|
messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(message => {
|
||||||
|
|
||||||
if (message.event === EVENTS.NotificationProgress) {
|
if (message.event === EVENTS.NotificationProgress) {
|
||||||
|
@ -262,7 +262,7 @@ export class EditChapterModalComponent implements OnInit {
|
|||||||
const selectedIndex = this.editForm.get('coverImageIndex')?.value || 0;
|
const selectedIndex = this.editForm.get('coverImageIndex')?.value || 0;
|
||||||
|
|
||||||
this.chapter.releaseDate = model.releaseDate;
|
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.genres = model.genres;
|
||||||
this.chapter.tags = model.tags;
|
this.chapter.tags = model.tags;
|
||||||
this.chapter.sortOrder = model.sortOrder;
|
this.chapter.sortOrder = model.sortOrder;
|
||||||
|
@ -26,7 +26,7 @@
|
|||||||
[class.is-invalid]="settingsForm.get('taskScanCustom')?.invalid && settingsForm.get('taskScanCustom')?.touched"
|
[class.is-invalid]="settingsForm.get('taskScanCustom')?.invalid && settingsForm.get('taskScanCustom')?.touched"
|
||||||
aria-describedby="task-scan-validations">
|
aria-describedby="task-scan-validations">
|
||||||
|
|
||||||
@if (settingsForm.dirty || settingsForm.touched) {
|
@if (settingsForm.dirty || !settingsForm.untouched) {
|
||||||
<div id="task-scan-validations" class="invalid-feedback">
|
<div id="task-scan-validations" class="invalid-feedback">
|
||||||
@if(settingsForm.get('taskScanCustom')?.errors?.required) {
|
@if(settingsForm.get('taskScanCustom')?.errors?.required) {
|
||||||
<div>{{t('required')}}</div>
|
<div>{{t('required')}}</div>
|
||||||
@ -65,7 +65,7 @@
|
|||||||
[class.is-invalid]="settingsForm.get('taskBackupCustom')?.invalid && settingsForm.get('taskBackupCustom')?.touched"
|
[class.is-invalid]="settingsForm.get('taskBackupCustom')?.invalid && settingsForm.get('taskBackupCustom')?.touched"
|
||||||
aria-describedby="task-scan-validations">
|
aria-describedby="task-scan-validations">
|
||||||
|
|
||||||
@if (settingsForm.dirty || settingsForm.touched) {
|
@if (settingsForm.dirty || !settingsForm.untouched) {
|
||||||
<div id="task-backup-validations" class="invalid-feedback">
|
<div id="task-backup-validations" class="invalid-feedback">
|
||||||
@if(settingsForm.get('taskBackupCustom')?.errors?.required) {
|
@if(settingsForm.get('taskBackupCustom')?.errors?.required) {
|
||||||
<div>{{t('required')}}</div>
|
<div>{{t('required')}}</div>
|
||||||
@ -105,7 +105,7 @@
|
|||||||
[class.is-invalid]="settingsForm.get('taskCleanupCustom')?.invalid && settingsForm.get('taskCleanupCustom')?.touched"
|
[class.is-invalid]="settingsForm.get('taskCleanupCustom')?.invalid && settingsForm.get('taskCleanupCustom')?.touched"
|
||||||
aria-describedby="task-scan-validations">
|
aria-describedby="task-scan-validations">
|
||||||
|
|
||||||
@if (settingsForm.dirty || settingsForm.touched) {
|
@if (settingsForm.dirty || !settingsForm.untouched) {
|
||||||
<div id="task-cleanup-validations" class="invalid-feedback">
|
<div id="task-cleanup-validations" class="invalid-feedback">
|
||||||
@if(settingsForm.get('taskCleanupCustom')?.errors?.required) {
|
@if(settingsForm.get('taskCleanupCustom')?.errors?.required) {
|
||||||
<div>{{t('required')}}</div>
|
<div>{{t('required')}}</div>
|
||||||
@ -151,7 +151,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for(task of recurringTasks$ | async; track task.lastExecutionUtc + task.cron; let idx = $index) {
|
@for(task of recurringTasks$ | async; track task; let idx = $index) {
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{{task.title | titlecase}}
|
{{task.title | titlecase}}
|
||||||
|
@ -1,94 +1,3 @@
|
|||||||
<ng-container *transloco="let t; read: 'entity-title'">
|
<ng-container *transloco="let t; read: 'entity-title'">
|
||||||
{{renderText | defaultValue}}
|
{{renderText | defaultValue}}
|
||||||
<!-- @switch (libraryType) {-->
|
|
||||||
<!-- @case (LibraryType.Comic) {-->
|
|
||||||
<!-- @if (titleName !== '' && prioritizeTitleName) {-->
|
|
||||||
<!-- @if (isChapter && includeChapter) {-->
|
|
||||||
<!-- {{t('issue-num') + ' ' + number + ' - ' }}-->
|
|
||||||
<!-- }-->
|
|
||||||
|
|
||||||
<!-- {{titleName}}-->
|
|
||||||
<!-- } @else {-->
|
|
||||||
<!-- @if (includeVolume && volumeTitle !== '') {-->
|
|
||||||
<!-- {{number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}}-->
|
|
||||||
<!-- }-->
|
|
||||||
<!-- {{number !== LooseLeafOrSpecial ? (isChapter ? t('issue-num') + number : volumeTitle) : t('special')}}-->
|
|
||||||
<!-- }-->
|
|
||||||
<!-- }-->
|
|
||||||
|
|
||||||
<!-- @case (LibraryType.ComicVine) {-->
|
|
||||||
<!-- @if (titleName !== '' && prioritizeTitleName) {-->
|
|
||||||
<!-- @if (isChapter && includeChapter) {-->
|
|
||||||
<!-- {{t('issue-num') + ' ' + number + ' - ' }}-->
|
|
||||||
<!-- }-->
|
|
||||||
|
|
||||||
<!-- {{titleName}}-->
|
|
||||||
<!-- } @else {-->
|
|
||||||
<!-- @if (includeVolume && volumeTitle !== '') {-->
|
|
||||||
<!-- {{number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}}-->
|
|
||||||
<!-- }-->
|
|
||||||
<!-- @if (number !== LooseLeafOrSpecial) {-->
|
|
||||||
<!-- {{isChapter ? t('issue-num') + number : volumeTitle}}-->
|
|
||||||
<!-- } @else {-->
|
|
||||||
<!-- {{t('special')}}-->
|
|
||||||
<!-- }-->
|
|
||||||
<!-- }-->
|
|
||||||
<!-- }-->
|
|
||||||
|
|
||||||
<!-- @case (LibraryType.Manga) {-->
|
|
||||||
<!-- @if (titleName !== '' && prioritizeTitleName) {-->
|
|
||||||
<!-- @if (isChapter && includeChapter) {-->
|
|
||||||
<!-- @if (number === LooseLeafOrSpecial) {-->
|
|
||||||
<!-- {{t('chapter') + ' - ' }}-->
|
|
||||||
<!-- } @else {-->
|
|
||||||
<!-- {{t('chapter') + ' ' + number + ' - ' }}-->
|
|
||||||
<!-- }-->
|
|
||||||
|
|
||||||
<!-- }-->
|
|
||||||
<!-- {{titleName}}-->
|
|
||||||
<!-- } @else {-->
|
|
||||||
<!-- @if (includeVolume && volumeTitle !== '') {-->
|
|
||||||
<!-- @if (number !== LooseLeafOrSpecial && isChapter && includeVolume) {-->
|
|
||||||
<!-- {{volumeTitle}}-->
|
|
||||||
<!-- }-->
|
|
||||||
<!-- }-->
|
|
||||||
|
|
||||||
<!-- @if (number !== LooseLeafOrSpecial) {-->
|
|
||||||
<!-- @if (isChapter) {-->
|
|
||||||
<!-- {{t('chapter') + ' ' + number}}-->
|
|
||||||
<!-- } @else {-->
|
|
||||||
<!-- {{volumeTitle}}-->
|
|
||||||
<!-- }-->
|
|
||||||
<!-- } @else if (fallbackToVolume && isChapter && volumeTitle) {-->
|
|
||||||
<!-- {{t('vol-num', {num: volumeTitle})}}-->
|
|
||||||
<!-- } @else {-->
|
|
||||||
<!-- {{t('special')}}-->
|
|
||||||
<!-- }-->
|
|
||||||
<!-- }-->
|
|
||||||
<!-- }-->
|
|
||||||
|
|
||||||
<!-- @case (LibraryType.Book) {-->
|
|
||||||
<!-- @if (titleName !== '' && prioritizeTitleName) {-->
|
|
||||||
<!-- {{titleName}}-->
|
|
||||||
<!-- } @else if (number === LooseLeafOrSpecial) {-->
|
|
||||||
<!-- {{null | defaultValue}}-->
|
|
||||||
<!-- } @else {-->
|
|
||||||
<!-- {{t('book-num', {num: volumeTitle})}}-->
|
|
||||||
<!-- }-->
|
|
||||||
<!-- }-->
|
|
||||||
|
|
||||||
<!-- @case (LibraryType.LightNovel) {-->
|
|
||||||
<!-- @if (titleName !== '' && prioritizeTitleName) {-->
|
|
||||||
<!-- {{titleName}}-->
|
|
||||||
<!-- } @else if (number === LooseLeafOrSpecial) {-->
|
|
||||||
<!-- {{null | defaultValue}}-->
|
|
||||||
<!-- } @else {-->
|
|
||||||
<!-- {{t('book-num', {num: (isChapter ? number : volumeTitle)})}}-->
|
|
||||||
<!-- }-->
|
|
||||||
<!-- }-->
|
|
||||||
|
|
||||||
<!-- @case (LibraryType.Images) {-->
|
|
||||||
<!-- {{number !== LooseLeafOrSpecial ? (isChapter ? (t('chapter') + ' ') + number : volumeTitle) : t('special')}}-->
|
|
||||||
<!-- }-->
|
|
||||||
<!-- }-->
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@ -187,12 +187,26 @@ export class EntityTitleComponent implements OnInit {
|
|||||||
|
|
||||||
private calculateComicRenderText() {
|
private calculateComicRenderText() {
|
||||||
let renderText = '';
|
let renderText = '';
|
||||||
if (this.titleName !== '' && this.prioritizeTitleName) {
|
|
||||||
|
// If titleName is provided and prioritized
|
||||||
|
if (this.titleName && this.prioritizeTitleName) {
|
||||||
if (this.isChapter && this.includeChapter) {
|
if (this.isChapter && this.includeChapter) {
|
||||||
renderText = translate('entity-title.issue-num') + ' ' + this.number + ' - ';
|
renderText = translate('entity-title.issue-num') + ' ' + this.number + ' - ';
|
||||||
}
|
}
|
||||||
renderText += this.titleName;
|
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;
|
return renderText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,8 +18,8 @@
|
|||||||
@if (editForm.get('name'); as formControl) {
|
@if (editForm.get('name'); as formControl) {
|
||||||
<app-setting-item [title]="t('name-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
<app-setting-item [title]="t('name-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
<ng-template #view>
|
<ng-template #view>
|
||||||
<input id="name" class="form-control" formControlName="name" type="text" readonly
|
<input id="name" class="form-control" formControlName="name" type="text"
|
||||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||||
@if (formControl.errors; as errors) {
|
@if (formControl.errors; as errors) {
|
||||||
@if (errors.required) {
|
@if (errors.required) {
|
||||||
<div class="invalid-feedback">{{t('required-field')}}</div>
|
<div class="invalid-feedback">{{t('required-field')}}</div>
|
||||||
@ -34,10 +34,10 @@
|
|||||||
<div class="row g-0">
|
<div class="row g-0">
|
||||||
<div class="mb-3 col-md-6 col-xs-12 pe-2">
|
<div class="mb-3 col-md-6 col-xs-12 pe-2">
|
||||||
@if (editForm.get('malId'); as formControl) {
|
@if (editForm.get('malId'); as formControl) {
|
||||||
<app-setting-item [title]="t('mal-id-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
<app-setting-item [title]="t('mal-id-label')" [toggleOnViewClick]="false" [showEdit]="false" [subtitle]="t('mal-tooltip')">
|
||||||
<ng-template #view>
|
<ng-template #view>
|
||||||
<input id="mal-id" class="form-control" formControlName="malId" type="number"
|
<input id="mal-id" class="form-control" formControlName="malId" type="number"
|
||||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-setting-item>
|
</app-setting-item>
|
||||||
}
|
}
|
||||||
@ -45,10 +45,10 @@
|
|||||||
|
|
||||||
<div class="mb-3 col-md-6 col-xs-12">
|
<div class="mb-3 col-md-6 col-xs-12">
|
||||||
@if (editForm.get('aniListId'); as formControl) {
|
@if (editForm.get('aniListId'); as formControl) {
|
||||||
<app-setting-item [title]="t('anilist-id-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
<app-setting-item [title]="t('anilist-id-label')" [toggleOnViewClick]="false" [showEdit]="false" [subtitle]="t('anilist-tooltip')">
|
||||||
<ng-template #view>
|
<ng-template #view>
|
||||||
<input id="anilist-id" class="form-control" formControlName="aniListId" type="number"
|
<input id="anilist-id" class="form-control" formControlName="aniListId" type="number"
|
||||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-setting-item>
|
</app-setting-item>
|
||||||
}
|
}
|
||||||
@ -58,10 +58,10 @@
|
|||||||
<div class="row g-0">
|
<div class="row g-0">
|
||||||
<div class="mb-3 col-md-6 col-xs-12 pe-2">
|
<div class="mb-3 col-md-6 col-xs-12 pe-2">
|
||||||
@if (editForm.get('hardcoverId'); as formControl) {
|
@if (editForm.get('hardcoverId'); as formControl) {
|
||||||
<app-setting-item [title]="t('hardcover-id-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
<app-setting-item [title]="t('hardcover-id-label')" [toggleOnViewClick]="false" [showEdit]="false" [subtitle]="t('hardcover-tooltip')">
|
||||||
<ng-template #view>
|
<ng-template #view>
|
||||||
<input id="hardcover-id" class="form-control" formControlName="hardcoverId" type="text"
|
<input id="hardcover-id" class="form-control" formControlName="hardcoverId" type="text"
|
||||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-setting-item>
|
</app-setting-item>
|
||||||
}
|
}
|
||||||
@ -69,10 +69,12 @@
|
|||||||
|
|
||||||
<div class="mb-3 col-md-6 col-xs-12">
|
<div class="mb-3 col-md-6 col-xs-12">
|
||||||
@if (editForm.get('asin'); as formControl) {
|
@if (editForm.get('asin'); as formControl) {
|
||||||
<app-setting-item [title]="t('asin-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
<app-setting-item [title]="t('asin-label')" [toggleOnViewClick]="false" [showEdit]="false" [subtitle]="t('asin-tooltip')">
|
||||||
<ng-template #view>
|
<ng-template #view>
|
||||||
<input id="asin" class="form-control" formControlName="asin" type="text"
|
<input id="asin" class="form-control" formControlName="asin" type="text"
|
||||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||||
|
|
||||||
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-setting-item>
|
</app-setting-item>
|
||||||
}
|
}
|
||||||
@ -100,7 +102,11 @@
|
|||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<p class="alert alert-primary" role="alert">
|
<p class="alert alert-primary" role="alert">
|
||||||
{{t('cover-image-description')}}
|
{{t('cover-image-description')}}
|
||||||
|
<!-- {{t('cover-image-description-extra')}}-->
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- <button class="btn btn-primary mb-2 w-100" (click)="downloadCover()" [disabled]="fetchDisabled">{{t('download-coversdb')}}</button>-->
|
||||||
|
|
||||||
<app-cover-image-chooser [(imageUrls)]="imageUrls"
|
<app-cover-image-chooser [(imageUrls)]="imageUrls"
|
||||||
(imageUrlsChange)="handleUploadByUrl($event)"
|
(imageUrlsChange)="handleUploadByUrl($event)"
|
||||||
(imageSelected)="updateSelectedIndex($event)"
|
(imageSelected)="updateSelectedIndex($event)"
|
||||||
|
@ -13,7 +13,7 @@ import {
|
|||||||
NgbNavOutlet
|
NgbNavOutlet
|
||||||
} from "@ng-bootstrap/ng-bootstrap";
|
} from "@ng-bootstrap/ng-bootstrap";
|
||||||
import {PersonService} from "../../../_services/person.service";
|
import {PersonService} from "../../../_services/person.service";
|
||||||
import { TranslocoDirective } from '@jsverse/transloco';
|
import {translate, TranslocoDirective} from '@jsverse/transloco';
|
||||||
import {CoverImageChooserComponent} from "../../../cards/cover-image-chooser/cover-image-chooser.component";
|
import {CoverImageChooserComponent} from "../../../cards/cover-image-chooser/cover-image-chooser.component";
|
||||||
import {forkJoin} from "rxjs";
|
import {forkJoin} from "rxjs";
|
||||||
import {UploadService} from "../../../_services/upload.service";
|
import {UploadService} from "../../../_services/upload.service";
|
||||||
@ -21,6 +21,7 @@ import {CompactNumberPipe} from "../../../_pipes/compact-number.pipe";
|
|||||||
import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component";
|
import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component";
|
||||||
import {AccountService} from "../../../_services/account.service";
|
import {AccountService} from "../../../_services/account.service";
|
||||||
import {User} from "../../../_models/user";
|
import {User} from "../../../_models/user";
|
||||||
|
import {ToastrService} from "ngx-toastr";
|
||||||
|
|
||||||
enum TabID {
|
enum TabID {
|
||||||
General = 'general-tab',
|
General = 'general-tab',
|
||||||
@ -57,6 +58,7 @@ export class EditPersonModalComponent implements OnInit {
|
|||||||
private readonly personService = inject(PersonService);
|
private readonly personService = inject(PersonService);
|
||||||
private readonly uploadService = inject(UploadService);
|
private readonly uploadService = inject(UploadService);
|
||||||
protected readonly accountService = inject(AccountService);
|
protected readonly accountService = inject(AccountService);
|
||||||
|
protected readonly toastr = inject(ToastrService);
|
||||||
|
|
||||||
protected readonly Breakpoint = Breakpoint;
|
protected readonly Breakpoint = Breakpoint;
|
||||||
protected readonly TabID = TabID;
|
protected readonly TabID = TabID;
|
||||||
@ -77,6 +79,7 @@ export class EditPersonModalComponent implements OnInit {
|
|||||||
selectedCover: string = '';
|
selectedCover: string = '';
|
||||||
coverImageReset = false;
|
coverImageReset = false;
|
||||||
touchedCoverImage = false;
|
touchedCoverImage = false;
|
||||||
|
fetchDisabled: boolean = false;
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
if (this.person) {
|
if (this.person) {
|
||||||
@ -91,6 +94,8 @@ export class EditPersonModalComponent implements OnInit {
|
|||||||
this.editForm.addControl('coverImageLocked', new FormControl(this.person.coverImageLocked, []));
|
this.editForm.addControl('coverImageLocked', new FormControl(this.person.coverImageLocked, []));
|
||||||
|
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
} else {
|
||||||
|
alert('no person')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -156,4 +161,15 @@ export class EditPersonModalComponent implements OnInit {
|
|||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
downloadCover() {
|
||||||
|
this.personService.downloadCover(this.person.id).subscribe(imgUrl => {
|
||||||
|
if (imgUrl) {
|
||||||
|
this.toastr.success(translate('toasts.person-image-downloaded'));
|
||||||
|
this.fetchDisabled = true;
|
||||||
|
this.imageUrls.push(imgUrl);
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -4,14 +4,14 @@ import {
|
|||||||
Component, DestroyRef,
|
Component, DestroyRef,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
Inject,
|
Inject,
|
||||||
inject,
|
inject, OnInit,
|
||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {ActivatedRoute, Router} from "@angular/router";
|
import {ActivatedRoute, Router} from "@angular/router";
|
||||||
import {PersonService} from "../_services/person.service";
|
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 {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 {ImageComponent} from "../shared/image/image.component";
|
||||||
import {ImageService} from "../_services/image.service";
|
import {ImageService} from "../_services/image.service";
|
||||||
import {
|
import {
|
||||||
@ -80,7 +80,6 @@ export class PersonDetailComponent {
|
|||||||
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
|
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
|
||||||
|
|
||||||
personName!: string;
|
personName!: string;
|
||||||
person$: Observable<Person> | null = null;
|
|
||||||
person: Person | null = null;
|
person: Person | null = null;
|
||||||
roles$: Observable<PersonRole[]> | null = null;
|
roles$: Observable<PersonRole[]> | null = null;
|
||||||
roles: PersonRole[] | null = null;
|
roles: PersonRole[] | null = null;
|
||||||
@ -89,42 +88,50 @@ export class PersonDetailComponent {
|
|||||||
filter: SeriesFilterV2 | null = null;
|
filter: SeriesFilterV2 | null = null;
|
||||||
personActions: Array<ActionItem<Person>> = this.actionService.getPersonActions(this.handleAction.bind(this));
|
personActions: Array<ActionItem<Person>> = this.actionService.getPersonActions(this.handleAction.bind(this));
|
||||||
chaptersByRole: any = {};
|
chaptersByRole: any = {};
|
||||||
|
private readonly personSubject = new BehaviorSubject<Person | null>(null);
|
||||||
|
protected readonly person$ = this.personSubject.asObservable();
|
||||||
|
|
||||||
constructor(@Inject(DOCUMENT) private document: Document) {
|
constructor() {
|
||||||
this.route.paramMap.subscribe(_ => {
|
this.route.paramMap.pipe(
|
||||||
const personName = this.route.snapshot.paramMap.get('name');
|
switchMap(params => {
|
||||||
if (personName === null || undefined) {
|
const personName = params.get('name');
|
||||||
|
if (!personName) {
|
||||||
this.router.navigateByUrl('/home');
|
this.router.navigateByUrl('/home');
|
||||||
return;
|
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.person$ = this.personService.get(this.personName).pipe(tap(p => {
|
this.roles$ = this.personService.getRolesForPerson(this.personName).pipe(
|
||||||
this.person = p;
|
tap(roles => {
|
||||||
|
|
||||||
this.themeService.setColorScape(this.person.primaryColor || '', this.person.secondaryColor);
|
|
||||||
|
|
||||||
this.roles$ = this.personService.getRolesForPerson(this.personName).pipe(tap(roles => {
|
|
||||||
this.roles = roles;
|
this.roles = roles;
|
||||||
this.filter = this.createFilter(roles);
|
this.filter = this.createFilter(roles);
|
||||||
|
this.chaptersByRole = {}; // Reset chaptersByRole for each person
|
||||||
|
|
||||||
for(let role of roles) {
|
// Populate chapters by role
|
||||||
this.chaptersByRole[role] = this.personService.getChaptersByRole(this.person!.id, role).pipe(takeUntilDestroyed(this.destroyRef));
|
roles.forEach(role => {
|
||||||
}
|
this.chaptersByRole[role] = this.personService.getChaptersByRole(person.id, role)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef));
|
||||||
|
});
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
}), takeUntilDestroyed(this.destroyRef));
|
}),
|
||||||
|
|
||||||
|
|
||||||
this.works$ = this.personService.getSeriesMostKnownFor(this.person.id).pipe(
|
|
||||||
takeUntilDestroyed(this.destroyRef)
|
takeUntilDestroyed(this.destroyRef)
|
||||||
);
|
);
|
||||||
|
|
||||||
this.cdRef.markForCheck();
|
// Fetch series known for this person
|
||||||
}), takeUntilDestroyed(this.destroyRef));
|
this.works$ = this.personService.getSeriesMostKnownFor(person.id).pipe(
|
||||||
});
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
createFilter(roles: PersonRole[]) {
|
createFilter(roles: PersonRole[]) {
|
||||||
@ -154,13 +161,7 @@ export class PersonDetailComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
if (this.person) {
|
loadPage(this.person!).subscribe();
|
||||||
loadPage(this.person).subscribe();
|
|
||||||
} else {
|
|
||||||
this.person$?.pipe(switchMap((p: Person) => {
|
|
||||||
return loadPage(p);
|
|
||||||
})).subscribe();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFilterByRole(role: PersonRole) {
|
loadFilterByRole(role: PersonRole) {
|
||||||
@ -191,7 +192,18 @@ export class PersonDetailComponent {
|
|||||||
|
|
||||||
ref.closed.subscribe(r => {
|
ref.closed.subscribe(r => {
|
||||||
if (r.success) {
|
if (r.success) {
|
||||||
|
const nameChanged = this.personName !== r.person.name;
|
||||||
this.person = {...r.person};
|
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();
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -191,7 +191,6 @@
|
|||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (showDetailsTab) {
|
|
||||||
<li [ngbNavItem]="TabID.Details" id="details-tab">
|
<li [ngbNavItem]="TabID.Details" id="details-tab">
|
||||||
<a ngbNavLink>{{t('details-tab')}}</a>
|
<a ngbNavLink>{{t('details-tab')}}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
@ -205,7 +204,6 @@
|
|||||||
}
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<!-- Min height helps with scroll jerking when switching from chapter -> related/details -->
|
<!-- Min height helps with scroll jerking when switching from chapter -> related/details -->
|
||||||
|
@ -262,7 +262,6 @@ export class VolumeDetailComponent implements OnInit {
|
|||||||
* This is the download we get from download service.
|
* This is the download we get from download service.
|
||||||
*/
|
*/
|
||||||
download$: Observable<DownloadEvent | null> | null = null;
|
download$: Observable<DownloadEvent | null> | null = null;
|
||||||
showDetailsTab: boolean = true;
|
|
||||||
currentlyReadingChapter: Chapter | undefined = undefined;
|
currentlyReadingChapter: Chapter | undefined = undefined;
|
||||||
|
|
||||||
maxAgeRating: AgeRating = AgeRating.Unknown;
|
maxAgeRating: AgeRating = AgeRating.Unknown;
|
||||||
@ -506,7 +505,6 @@ export class VolumeDetailComponent implements OnInit {
|
|||||||
this.setContinuePoint();
|
this.setContinuePoint();
|
||||||
|
|
||||||
|
|
||||||
this.showDetailsTab = hasAnyCast(this.volumeCast) || (this.genres || []).length > 0 || (this.tags || []).length > 0;
|
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
});
|
});
|
||||||
|
@ -2024,13 +2024,19 @@
|
|||||||
"name-label": "{{edit-series-modal.name-label}}",
|
"name-label": "{{edit-series-modal.name-label}}",
|
||||||
"role-label": "Role",
|
"role-label": "Role",
|
||||||
"mal-id-label": "MAL Id",
|
"mal-id-label": "MAL Id",
|
||||||
|
"mal-tooltip": "https://myanimelist.net/people/{MalId}/",
|
||||||
"anilist-id-label": "AniList Id",
|
"anilist-id-label": "AniList Id",
|
||||||
|
"anilist-tooltip": "https://anilist.co/staff/{AniListId}/",
|
||||||
"hardcover-id-label": "Hardcover Id",
|
"hardcover-id-label": "Hardcover Id",
|
||||||
|
"hardcover-tooltip": "https://hardcover.app/authors/{HardcoverId}",
|
||||||
"asin-label": "ASIN",
|
"asin-label": "ASIN",
|
||||||
|
"asin-tooltip": "https://www.amazon.com/stores/J.K.-Rowling/author/{ASIN}",
|
||||||
"description-label": "Description",
|
"description-label": "Description",
|
||||||
"required-field": "{{validations.required-field}}",
|
"required-field": "{{validations.required-field}}",
|
||||||
"cover-image-description": "{{edit-series-modal.cover-image-description}}",
|
"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": {
|
"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?",
|
"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",
|
"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-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": {
|
"read-time-pipe": {
|
||||||
|
@ -99,7 +99,7 @@ const languageCodes = [
|
|||||||
'syr', 'syr_SY', 'ta', 'ta_IN', 'te', 'te_IN', 'th', 'th_TH', 'tl', 'tl_PH', 'tn',
|
'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',
|
'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',
|
'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 = {
|
const translocoOptions = {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user